Build & deploy
vite build renders every page into dist/client/. That directory is the site, and any static host can serve it. This page covers the shape of the output, how a page arrives and navigates, what the host has to do, and 404.html and sitemap.xml.
The output
dist/client/ is the site: for every page, its HTML and the same page as an RSC payload, index.rsc. dist/rsc/ and dist/ssr/ are the machinery that produced it, not something to serve.
dist/
client/
index.html
index.rsc
products/
index.html
index.rsc
1/
index.html
index.rsc
old/
index.html
404.html
sitemap.xml
assets/
rsc/
ssr/The build ends by reporting in one line how many routes it wrote, and whether it wrote 404.html and sitemap.xml; the count includes redirect pages.
k8ordo: wrote 4 routes and 404.html and sitemap.xmlHow a page arrives and navigates
A page arrives as HTML with the payload it was rendered from written into it, so hydration reads what the build rendered instead of asking for the page again. From there, a link to another page fetches that page's index.rsc rather than reloading the document: navigation stays client-side even though the site is a pile of files.
The payload lives at a path rather than behind a header or a query because static hosting varies on neither.
What the host has to do
Any host that serves dist/client/ as it is will do. It has to do three things:
- Answer a URL like
/products/1withproducts/1/index.html - Serve the
index.rscfiles as they are; any content type other than HTML is accepted - Answer a URL it does not have with
404.html; whether that answer carries a 404 status is the host's setting
A URL the site does not have is nobody's to render in the browser: the answer is not a payload, so the navigation becomes an ordinary document load and the host answers it with 404.html. The same goes for files sitting beside the site, so a link to /robots.txt fetches the file instead of disappearing into the router.
Mark a link the host answers with a download as <a href="/report.csv" download>: the navigate event then already marks it as a download, so the router leaves it to the browser, whereas Content-Disposition only arrives with the answer, after the URL has been committed.
404.html
not-found.tsx becomes 404.html, the file most static hosts serve for an unknown URL — which is why declaring a not-found page means something in this mode, even though nothing is running to route the request.
A host has one blanket 404, so only one not-found.tsx can be represented, wherever it sits — under a locale segment is fine. A table declaring two stops the build rather than silently picking one.
a static host answers every unknown URL from one file, so only one not-found.tsx can be represented — this table declares /docs/*, /*That one file is rendered for a pathname the site does not have, which is what any 404 is. Its pathname is such a URL, and where a parameter sits above not-found.tsx, that parameter is filled with a segment no route declared — so params.<name> there is not a value the application named. Treat it as you would under @k8ordo/server, where /:locale/* matches /fr/anything too: validate it, and read what the visitor actually opened from usePathname() in a client component after hydration. A visitor without JavaScript keeps whatever the build rendered.
This site's LocaleShell does exactly that (excerpted below): it checks the value with locales.is(), and otherwise reads the locale from the URL — which is why 404.html is served in Japanese and turns English the moment it hydrates on an /en/… URL.
// src/routes/[locale]/_parts/locale-shell.tsx
'use client';
import { usePathname } from '@k8ordo/router';
import { UIProvider } from '@k8ordo/ui';
import { dictionaries } from '@k8ordo/ui/i18n';
import type { ReactNode } from 'react';
import { locales } from '../../../i18n';
export function LocaleShell({
locale: param,
children,
}: {
locale: string;
children: ReactNode;
}) {
const pathname = usePathname();
const locale = locales.is(param)
? param
: (locales.delocalize(pathname).locale ?? locales.default);
return <UIProvider messages={dictionaries[locale]}>{children}</UIProvider>;
}With no not-found.tsx, no 404.html is written, and the host decides what an unknown URL gets.
site and sitemap.xml
Pass the origin the site is served from — https://example.com — as site, and the build also writes sitemap.xml, listing every page it wrote. Redirects and 404.html are not pages and are left out. Without the origin there is no sitemap, because a sitemap of relative URLs is not one.
// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
framework({
paths: () => ['/products/1'],
site: 'https://example.com',
}),
],
});<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/</loc></url>
<url><loc>https://example.com/products</loc></url>
<url><loc>https://example.com/products/1</loc></url>
</urlset>URLs are sorted, and a trailing slash on site is ignored.
The options of framework()
The options are typed StaticOptions.
| Option | Default | Meaning |
|---|---|---|
routesDir | 'src/routes' | The route directory, relative to the project root. The generated files still go to .k8ordo/, and problem lines still begin with routes/. |
paths | None | A function that receives the parameterised patterns and returns concrete pathnames, or a promise of them. |
site | None | The origin the site is served from; with it the build writes sitemap.xml. |
// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [framework({ routesDir: 'app/routes' })],
});When the build stops
Rather than write a site that is missing pages or broken in production, the build stops. These are the main reasons:
- A problem in the
routes/grammar, or a shadowed route — routes/ - A parameterised route with no pathnames, or a supplied pathname that no route wants, that a schema refuses, or that cannot be decoded or leaves the output — Parameters
- A
paramsSchemathat validates asynchronously — Parameters - More than one
not-found.tsx— Errors & redirects - A
redirect.tswhose target names a param its own pattern lacks — Errors & redirects - A module that declares
'use server'— Get Started - A component that throws while the build renders it — a Server Component always, a client component when no Suspense boundary sits above it — Errors & redirects
- A
server-onlymodule that reaches the client — Boundaries
What static cannot do
Anything that needs the request: Server Actions and redirect() from them, the request a page reads under @k8ordo/server, and status codes the application decides. A file cannot receive a form submission, and whether 404.html is served with a 404 rather than a 200 is the host's setting — the build can write the page, but not the response.
If the application needs any of that, it wants @k8ordo/server, and everything else stays exactly as it is. @k8ordo/server