@k8ordo/static

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.xml

How 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/1 with products/1/index.html
  • Serve the index.rsc files 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.

OptionDefaultMeaning
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/.
pathsNoneA function that receives the parameterised patterns and returns concrete pathnames, or a promise of them.
siteNoneThe 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 paramsSchema that validates asynchronously Parameters
  • More than one not-found.tsx Errors & redirects
  • A redirect.ts whose 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-only module 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