---
title: Static generation
---

# Static generation

Build HTML files at compile time. `renderToStatic` gives you a context to
render all your pages, then emit fragment files if any page uses `<Defer>` with
a lazy factory. The browser fetches fragments after the shell page loads: no
streaming connection required.

## When to use static generation

Static generation is the right choice when:

- Your content is known at build time (docs, blogs, marketing sites)
- You deploy to a CDN or static file server (S3, Nginx, Cloudflare Pages)
- You want zero server infrastructure for serving pages
- Fragments can be fetched lazily by the client

If you need fragments to arrive in the same HTTP response as the shell, use
[Streaming](/integration/streaming) instead.

## Pure static

No lazy `<Defer>` in your pages? Call `renderToStatic` without an adapter.
Each `ctx.renderPage` call applies any configured shell transforms and returns
a complete HTML document, ready to write to a file.

```tsx
import type { JSX } from "@vincle/core";

import { renderToStatic } from "@vincle/flow";
import { writeFile } from "node:fs/promises";

declare const pages: { Component: () => JSX.Element; out: string }[];

await renderToStatic(async (ctx) => {
  await Promise.all(
    pages.map(async (page) => {
      const html = await ctx.renderPage(() => <page.Component />);
      await writeFile(page.out, "<!DOCTYPE html>\n" + html);
    }),
  );
});
```

## With deferred fragments

Pass an adapter and call `ctx.emitFragments` after rendering all pages. Each
deferred fragment is rendered, already `Frame`-wrapped, and passed to your
callback with its id, its URL and its HTML.

```tsx
import type { JSX } from "@vincle/core";

import { renderToStatic } from "@vincle/flow";
import { NativeAdapter } from "@vincle/flow/adapters";
import { writeFile } from "node:fs/promises";

declare const pages: { Component: () => JSX.Element; out: string }[];

await renderToStatic(
  async (ctx) => {
    for (const page of pages) {
      const html = await ctx.renderPage(() => <page.Component />);
      await writeFile(page.out, "<!DOCTYPE html>\n" + html);
    }

    // One .html file per deferred fragment.
    await ctx.emitFragments((_id, url, html) => writeFile("./dist" + url, html));
  },
  { adapter: NativeAdapter },
);
```

The browser loads the shell page first; each `<Defer>` placeholder carries its
fragment's URL, and the adapter fetches it. Fragment files are static HTML at
predictable URLs, so they can be cached aggressively.

The `generatePath` option sets each fragment's URL:

| Option         | Default                              | Description                |
| -------------- | ------------------------------------ | -------------------------- |
| `generatePath` | ``(id) => `/fragments/${id}.html` `` | URL path for each fragment |

## On-demand regeneration

A full rebuild is wasteful when only one piece of data changed: a price, a
stock count, a comment count. Because each fragment is a standalone file at a
predictable URL, only that file needs to change; the shell page that
references it is untouched.

`renderFragment` produces exactly the bytes a full build would have written
for one `id`, without rendering any page:

```tsx
import { renderFragment } from "@vincle/flow";
import { NativeAdapter } from "@vincle/flow/adapters";

declare function fetchPrice(symbol: string): Promise<{ value: number }>;

// A Netlify/Vercel Edge Function: both run standard Request → Response
// handlers, so this needs no platform SDK import.
export default async function handler(req: Request): Promise<Response> {
  const symbol = new URL(req.url).searchParams.get("symbol");
  if (!symbol) return new Response("Missing symbol", { status: 400 });

  const price = await fetchPrice(symbol);
  const { url, html } = await renderFragment(
    `price-${symbol}`,
    <span>{price.value.toFixed(2)}</span>,
    { adapter: NativeAdapter },
  );

  // `url` matches the path the full build already wrote this fragment to,
  // upload `html` there (blob store, on-demand revalidation, CDN purge +
  // PUT…). The shell page that includes it never needs rebuilding.
  return Response.json({ url, html });
}
```

How you publish those bytes at `url` (a blob store, a revalidation call, a CDN
purge and re-upload) depends on the host and stays in your handler. See
[`renderFragment`](/api/flow/renderFragment) for the full API.
