Skip to content
Vincle

Loading…

    HTTP serving

    Serve Vincle-rendered pages over HTTP: with streaming, negotiation, and minimal boilerplate. For pre-rendered files, see Static generation.

    Using serve()#

    The serve() helper creates a Response from a page component and an adapter. It handles streaming, headers, and content negotiation in one call.

    import { serve } from "@vincle/flow/http";
    import { NativeAdapter } from "@vincle/flow/adapters";
    Bun.serve({
    port: 3000,
    async fetch(req) {
    return serve(req, () => <Page />, NativeAdapter);
    },
    });

    The response body is a stream: the shell is flushed immediately: the browser starts parsing HTML while fragments are still rendering.

    Signature#

    serve(
    req: Request,
    page: (negotiation: Negotiation) => JSX.Element,
    adapter: StreamingAdapter,
    options?: FlowOptions & ResponseInit & {
    negotiate?: Negotiate;
    mode?: "full" | "fragment";
    },
    ): Promise<Response>

    Options#

    Option Type Default Description
    signal AbortSignal N/A Abort all pending renders
    onError (error, info) => JSX.Element | void N/A Per-fragment error handler
    defaultTimeout number N/A Per-fragment timeout (ms)
    negotiate (req: Request) => Negotiation N/A Extract per-request hints
    mode "full" | "fragment" "full" Render full page or fragment-only
    headers HeadersInit N/A Additional response headers
    status number 200 Response status code
    statusText string N/A Response status text

    Using Node.js http#

    For environments where Response is not available, use renderToStream directly and write each chunk to your response as it comes, without buffering. The same pattern works with Express, Fastify, Hono, or anything built on node:http.

    import { Slot, Defer, renderToStream } from "@vincle/flow";
    import { NativeAdapter } from "@vincle/flow/adapters";
    import http from "node:http";
    declare function fetchComments(): Promise<{ text: string }[]>;
    async function Comments() {
    const items = await fetchComments();
    return (
    <ul>
    {items.map((c) => (
    <li>{c.text}</li>
    ))}
    </ul>
    );
    }
    function Page() {
    return (
    <html>
    <body>
    <h1>My page</h1>
    <Slot name="comments">
    <p>Loading comments…</p>
    </Slot>
    <Defer target="comments">{() => <Comments />}</Defer>
    </body>
    </html>
    );
    }
    http
    .createServer(async (_req, res) => {
    const stream = renderToStream(() => <Page />, NativeAdapter);
    // Shell flushes first; deferred fragments follow as they resolve.
    res.writeHead(200, {
    "Content-Type": "text/html; charset=utf-8",
    "Transfer-Encoding": "chunked",
    });
    for await (const chunk of stream) {
    res.write(chunk);
    }
    res.end();
    })
    .listen(3000);
    console.log("Listening on http://localhost:3000");

    HTMX negotiation#

    HTMX sends headers like HX-Target to indicate which fragment a request expects. Vincle provides negotiateHtmx to handle this automatically:

    import { serve, negotiateHtmx } from "@vincle/flow/http";
    import { HtmxAdapter } from "@vincle/flow/adapters";
    Bun.serve({
    port: 3000,
    async fetch(req) {
    return serve(req, () => <Page />, HtmxAdapter, {
    negotiate: negotiateHtmx,
    });
    },
    });

    The response includes Vary: HX-Target so shared caches never serve a fragment response to a full-page navigation, or vice versa.

    Custom negotiation#

    Write your own negotiator for any client library that uses custom headers:

    import type { Negotiate } from "@vincle/flow";
    const negotiateMyLib: Negotiate = (req) => {
    const fragment = req.headers.get("X-Fragment-Id") ?? undefined;
    return {
    headers: { Vary: "X-Fragment-Id" },
    target: fragment,
    };
    };

    Full-page vs fragment mode#

    By default, every request renders the full page: shell + all deferred fragments. Use mode: "fragment" to suppress the shell and render only the targeted fragment's content. This is useful when a client library fetches a single fragment independently (an HTMX hx-get, a Turbo <turbo-frame src>).

    serve(req, () => <Page />, NativeAdapter, { mode: "fragment" });

    A mode passed to serve() overrides the one returned by the negotiator.