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.