Skip to content
Pitlane

Prerendering

A page whose content does not change per request does not need a server on the critical path. remix({ prerender }) renders those pages during vite build and writes the HTML into the client output, where a CDN serves it without waking anything up.

ts
// vite.config.ts
import { remix } from "@pitlane/dev";
import { defineConfig } from "vite";

export default defineConfig({
    plugins: [remix({ prerender: ["/", "/blog", "/blog/hello-world"] })],
});

There is no separate rendering path. The build creates a Request, sends it through the same fetch handler production runs, and keeps the response. Route handlers, middleware, and components behave exactly as they do at runtime, because they are the same code answering the same request.

Where the paths come from

Four shapes, in order of how much the app knows about its own URLs.

Everything static

true prerenders every path the app's route map can serve with no params.

ts
remix({ prerender: true });

/ and /blog qualify. /blog/:slug does not, because the slugs live outside the route map. For this to work the server entry has to export the route map alongside its handler:

ts
// app/entry.server.tsx
export { routes } from "./routes.ts";
export default router;

Without that export the build fails and says so, rather than quietly prerendering nothing.

An explicit list

ts
let slugs = getPostSlugs();

remix({
    prerender: ["/", "/blog", ...slugs.map(slug => `/blog/${slug}`)],
});

A function

When the list needs a database, a CMS, or a filesystem walk, pass a function. It receives getStaticPaths(), which is the true behavior on demand, so the per-slug half is the only part you write:

ts
remix({
    async prerender({ getStaticPaths }) {
        let slugs = await getPostSlugsFromCMS();
        return [
            ...getStaticPaths(), // "/" and "/blog"
            ...slugs.map(slug => `/blog/${slug}`),
        ];
    },
});

The object form

paths takes any of the three above, and two more options come with it:

ts
remix({
    prerender: {
        paths: true,
        concurrency: 4,
        spider: false,
    },
});

concurrency renders that many paths at once. Rendering is CPU-bound in process, so the gain depends on how much of a render waits on I/O; start at the default of 1 and measure.

Spidering

spider: true turns the path list into a set of starting points. Every rendered page is scanned for links, and those get rendered too:

ts
remix({ prerender: { paths: ["/"], spider: true } });

One starting path can be the whole config for a site whose pages all link to each other. Anything reachable gets built, including the page you forgot to list.

Crawling stops where a crawler should stop: rel="nofollow" links, <meta name="robots" content="nofollow"> pages, other origins, and non-navigable hrefs like mailto:. That is @pitlane/crawler underneath, which is installable on its own for sitemaps, link checks, and static exports the plugin does not cover. The crawling guide walks through those.

Output on disk

Each path becomes an index.html under the client output, so a static host serves it back for the original URL:

dist/client/
├─ index.html                    ← /
├─ blog/
│  ├─ index.html                 ← /blog
│  └─ hello-world/
│     └─ index.html              ← /blog/hello-world
└─ assets/
   ├─ entry.browser-<hash>.js
   └─ index-<hash>.css

The build logs each file as it writes it.

Prerendering runs last, after both environments are built and the assets manifest is written. That ordering is what makes the HTML on disk identical to the HTML the runtime server would produce: the ?assets= imports resolve to real hashed chunk URLs, not dev paths.

If the app is served from a sub-path, declare the routes under it (the GitHub Pages guide shows the pattern) and set Vite's base to match. The rendered files still go to the top of the client output, because the host mounts that whole directory at the base.

Paths that redirect

A redirect is not a page, so nothing is written for one. prerender: true asks for every static path in the route map, and a route answering 302 is an ordinary thing to find in there: a / pointing at the real landing path, or a URL that moved. The build logs what it skipped and carries on:

prerendered nyc/index.html
skipped / (redirects to /nyc)

The app still answers that path at runtime, which is the behavior the redirect was for. Nothing about it changes.

Under spider: true a redirect is followed rather than skipped, because following links is what spidering is. A crawl seeded at a / that points elsewhere still reaches the site instead of stopping at the door.

Any other failing response is a real failure and stops the build:

error during build:
Error: Crawl failed: 404 Not Found (/blog/renamed-post)

A listed path that 404s is a stale prerender list, and a spidered one is a dead internal link. Both are worth knowing about before a deploy rather than after.

Serving the output

With server: true, which is the default, prerendering is an optimization rather than a deployment mode. The server is still there. Put the client output in front of it and requests for a prerendered path never reach the handler; every other path renders as usual. A staticFiles() middleware in the server entry does this in one line, and most CDNs do it in front of the origin.

Hydration is unaffected. The HTML carries the same island markers a runtime render produces, and the same client entry picks them up.

Data that goes stale

A prerendered page is frozen at build time. That is the point, and it is also the constraint: a page showing anything that changes between deploys should not be in the list.

There is no revalidation mechanism here, and no incremental regeneration. Rebuild and redeploy, or leave the path out and let the server render it.

Not available without a server

remix({ server: false, prerender }) throws. Prerendering renders through the server entry, and SPA mode builds no server, so there is nothing to render with. The two are alternatives: SPA mode serves one shell that hydrates any path, prerendering serves real HTML per path.

Bundles Node cannot run

Prerendering imports the built server bundle and calls its fetch handler in the build process. A bundle built for another runtime does not load there. A Workers bundle opens with import { env } from "cloudflare:workers", which Node has no answer for.

That is not a restriction on prerendering, only on how the request gets to the app. When the import fails, the build starts the project's own preview server and renders through that instead:

ts
// vite.config.ts
import { cloudflare } from "@cloudflare/vite-plugin";
import { remix } from "@pitlane/dev";
import { defineConfig } from "vite";

export default defineConfig({
    plugins: [remix({ prerender: true }), cloudflare()],
});

Nothing there names the runtime twice. @cloudflare/vite-plugin already supplies a preview server that boots workerd with the app's real bindings, so the pages written to dist/client come from the runtime that will serve them. env reads resolve, and navigator.userAgent says Cloudflare-Workers. The same holds for any platform plugin that contributes a preview server.

One thing does change on this path. getStaticPaths() normally reads the routes export off the built bundle, and that bundle is the thing that will not load. So the build follows the export back to its source instead:

ts
// app/entry.server.tsx
import { routes } from "./routes.ts";

export { routes };
export default router;

Either spelling works. export { routes } from "./routes.ts" says the same thing in one line. What the build needs is a module to point at. A route map written inline in the server entry has none, and the build says so; move it to its own file, which is where it belongs anyway.