@k8ordo/framework

Deployment

Publish what vite build writes. In static mode, dist/client/ goes on a static host, and in server mode dist/ goes on the server and starts with node dist/server.js.

On this page

Static mode

In static mode, a static host serves dist/client/ and nothing else.

Build output

text
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/
  package.json
  • index.html: the page at that URL, with the payload it was rendered from embedded.
  • index.rsc: the same page as a payload, fetched on a client navigation.
  • old/index.html: the page written from a redirect.ts, which redirects to the target. No index.rsc sits beside it.
  • 404.html: not-found.tsx, rendered.
  • sitemap.xml: written only when site is given.
  • assets/: the client’s scripts and styles.

How hydration and navigation use the payloads is covered in How it works.

The rest of dist/ rendered the pages, and is not served.

vite build
k8ordo: wrote 4 routes and 404.html and sitemap.xml

The build ends with one line: how many routes it wrote, and whether it wrote 404.html and sitemap.xml. Redirect pages count among the routes. When it wrote shells, the line also gives how many and where, and _redirects.

Static hosts

The host has to do three things.

  • Return products/1/index.html for a URL like /products/1
  • Serve index.rsc as it is, with any Content-Type but HTML
  • Return 404.html for a URL it does not have

A navigation to a URL the site does not have gets a response that is not a payload. It turns into an ordinary document load, and the host returns 404.html for it. A link to another file on the same origin, such as /robots.txt, works the same way: the file itself opens.

Information

Note

Whether 404.html goes out with a 404 status is the host’s setting. The build can write the page, but not the response.

Download links

html
<a download href="/report.csv">Report</a>

Add the download attribute to a link to a file the host serves as a download. The router then leaves the link alone, and the browser downloads the file.

What 404.html holds

404.html is rendered once, for a URL the site does not have. A dynamic segment above not-found.tsx gets a value no route declared.

The browser does not hydrate this file. It renders the page again at the URL the visitor opened. A Client Component reads the visitor’s URL with usePathname() from its first render.

This site’s 404.html works that way. The file is written in Japanese, and opened at an /en/… URL it turns English once the browser renders it again. Without JavaScript, the Japanese the build wrote stays.

sitemap.xml

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    framework({
      mode: 'static',
      paths: () => ['/products/1'],
      site: 'https://example.com',
    }),
  ],
});
dist/client/sitemap.xml
<?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>

Give site the origin the site is served from, and the build also writes sitemap.xml, listing every page it wrote. Redirects, route.ts responses and 404.html are not pages, and are left out.

Without site, no sitemap.xml is written.

_redirects

dist/client/_redirects
# @k8ordo/framework: built URLs the rules below would also catch
/posts/1 /posts/1 200
/posts/1/index.rsc /posts/1/index.rsc 200
/posts/2 /posts/2 200
/posts/2/index.rsc /posts/2/index.rsc 200
# @k8ordo/framework: values the build did not write, answered by their fallback.tsx's shell
/posts/:p1 /posts/!fallback/ 200
/posts/:p1/index.rsc /posts/!fallback/index.rsc 200

When a page has a fallback.tsx, the build writes dist/client/_redirects along with the shell: rewrite rules that Netlify and Cloudflare read. /posts/3, which has no file, gets the shell, and its payload URL the shell’s payload, with the URL left as it is. Shells are covered in Parameters.

The first block answers each URL the build wrote with its own file. Cloudflare applies rules before files, and without these it would answer written pages with the shell too. Netlify serves files first and never reaches them.

A public/_redirects of your own goes between the two blocks. A rule there such as /* /index.html 200 matches before the shells’ rules, and the build warns about it.

Under a Vite base, _redirects is still written at the top of dist/client/, with the base on every rule. A host reads _redirects only at the top of what it publishes, so move it there.

Information

Note

For a pattern of two parameters and nothing else, such as /:lang/:id, the shell’s rule also matches a missing /assets/x.js, and answers it with HTML.

Cloudflare

On Workers static assets, keep html_handling at the default auto-trailing-slash, or set force-trailing-slash: the shell rules rewrite to a directory URL that ends in a slash. Under drop-trailing-slash the redirects loop, and under none the shell is a 404. Cloudflare also tells a trailing slash apart: a value the build did not write, asked for as /posts/3/, matches no shell rule and gets a 404, where other hosts answer it with the shell.

To serve 404.html, set not_found_handling to "404-page". A Worker script that runs before the files bypasses _redirects. Pages handles slashes as auto-trailing-slash does, with no setting to change it.

Cloudflare reads at most 2,000 rules for fixed URLs and 100 with parameters. Each value written under a pattern with a shell takes two fixed rules, so about 1,000 such pages fit. The build warns when a limit is passed.

Vercel

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { vercel } from '@k8ordo/framework/vercel';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [framework({ mode: 'static' }), vercel()],
});

Vercel does not read _redirects. Put vercel() beside framework(), and the build also writes .vercel/output/: the written files as static files, and the same rewrites as routes, with no function.

A build on Vercel that writes shells without vercel() warns: nothing would serve them.

Other hosts

On a host that does not read _redirects, set up these two rewrites for each shell location. Both answer with a file and leave the URL as it is, and apply only when no file answers.

  • /posts/:id to /posts/!fallback/index.html
  • /posts/:id/index.rsc to /posts/!fallback/index.rsc (without it, a navigation turns into a document load)

:id matches one segment. When a redirect.ts or route.ts was written under a shell location, answer its payload URL with the shell’s HTML, in a rule placed before the second one. A host that applies rules before files needs, first of all, a rule answering each written file with itself.

A host without rewrites, such as GitHub Pages, cannot use shells: a value the build did not write gets 404.html.

Checking locally

bash
vite build
vite preview

vite preview serves a static build the way a host does: the files in dist/client/, then the rewrites in _redirects, applied as Netlify applies them. When nothing matches it answers 404.html with a 404, and any method but GET and HEAD with a 405.

Nothing is rendered again, so what you check is the files as written, CSP <meta> included. serve() from @k8ordo/framework/serve serves a static build the same way.

Server mode

In server mode, the build writes a handler that renders a page per request, and a server that runs it.

Build output

text
dist/
  server.js
  package.json
  rsc/
    index.js
  ssr/
  client/
    assets/
      index-1a2b.js
      index-1a2b.js.br
      index-1a2b.js.gz

dist/client/ holds no page HTML.

  • dist/server.js: the server, started with Node.js.
  • dist/package.json: makes the .js files in dist/ load as ES modules.
  • dist/rsc/index.js: the request handler.
  • dist/ssr/: what the handler uses to turn a payload into HTML.
  • dist/client/: the files served to the browser. A file whose type compresses gets a Brotli and a gzip copy beside it (.br and .gz).

The build bundles every dependency the server and the handler use into dist/. Only Node.js’s built-in modules stay outside. Nothing reads node_modules at run time, so @k8ordo/framework and @k8ordo/router go in devDependencies.

Starting

bash
PORT=8080 HOST=0.0.0.0 node dist/server.js

Put dist/ on the server and start it with node dist/server.js. Nothing needs installing: it runs where neither the application’s package.json nor node_modules exists.

  • PORT: the port to listen on. 3000 by default.
  • HOST: the host to listen on. The default localhost accepts connections from the same machine only, so inside a container set it to 0.0.0.0. Node listens on one of the addresses localhost names, often only ::1, so point a proxy on the same machine at localhost rather than 127.0.0.1, or set HOST=127.0.0.1.

On SIGTERM or SIGINT it stops taking connections, finishes the answers already under way, and exits with 0. Started as a container’s first process, it still stops as soon as it is told to.

Dependencies that cannot be bundled

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [framework({ mode: 'server' })],
  environments: {
    rsc: { resolve: { external: ['better-sqlite3'] } },
  },
});

A dependency that ships a native binary, or reads its own files by path, stops working once bundled. Name it in the rsc environment’s resolve.external, and that dependency alone is left out of the bundle.

Keep a dependency left out this way in dependencies and install it for run time. Install without development dependencies, as npm install --omit=dev does, and start dist/server.js inside the application’s directory.

serve()

serve.js
import { serve } from '@k8ordo/framework/serve';

const server = await serve({ port: 3000, host: '0.0.0.0' });
console.log(server.url);

dist/server.js runs serve() from @k8ordo/framework/serve. To call it from a server of your own, put @k8ordo/framework in dependencies and install it for run time as well. It takes three options.

  • dist: the build output directory. dist by default, resolved from the working directory.
  • port: the port to listen on. 3000 by default; 0 lets the system pick a free one.
  • host: the host to listen on. The default localhost accepts connections from the same machine only, so inside a container pass 0.0.0.0.

The return value has url, port and close(), so a test can start a server against the build and stop it when done.

How serve() responds

A GET or HEAD that names a file in dist/client/ is answered with that file. Every other request goes to the handler.

  • Caching: files under /assets/ carry a hash of their contents in their names. They are sent immutable and cached for a year. Any other file is no-cache.
  • Compression: a file with compressed copies is sent in the one Accept-Encoding prefers. Pages and payloads are compressed as they stream.
  • Revalidation: a file’s ETag is derived from its contents, so a revalidation ends in a 304 even against another server built from the same source.
  • Ranges: a Range asking for one span gets a 206, which Safari needs before it plays a <video>.
  • Failure: when the handler throws, the response is a 500 with the body internal error. The error itself goes to the server’s log, not to the visitor.

Even with .. or %2e%2e in the path, no file outside dist/client/ is served.

Other runtimes

render.js
import handler from './dist/rsc/index.js';

const response = await handler(
  new Request('https://example.com/products/1'),
);
console.log(response.status);

dist/rsc/index.js default-exports a function of type (request: Request) => Promise<Response>. The handler loads dist/ssr/ from beside itself, so keep the two in the same place. Its dependencies are bundled in, so nothing needs installing.

js
// Deno
Deno.serve(handler);

// Bun
Bun.serve({ fetch: handler });

// Cloudflare Workers
export default { fetch: handler };

Beyond the web’s Request, Response and streams, the only thing the handler needs from its runtime is AsyncLocalStorage from node:async_hooks. Node.js, Bun, Deno, and Cloudflare Workers with nodejs_compat all take the imported handler as it is.

wrangler.jsonc
{
  "main": "worker.js",
  "compatibility_flags": ["nodejs_compat"],
  "assets": { "directory": "dist/client" }
}

The handler serves no files. Serve dist/client/ in front of it, and pass it only the requests that match no file. On Workers, set assets.directory in wrangler.jsonc to dist/client.

Warning

Pitfall

A route.ts or Server Action can import a Node.js-only module such as node:fs. The application then runs only on a runtime that has it.

Behind a proxy

The handler accepts a POST by comparing the host in its Origin header with the host of the URL, so the Request has to carry the URL the visitor asked for. The check is covered in Server Actions.

serve() builds the URL from the request’s Host header and reads no forwarding header. A proxy in front of it has to pass the original Host on unchanged. A request line that names a host of its own (GET http://…), or a Host that is not a host, gets a 400.

A host that builds the URL from a path itself appends the path to the origin as text rather than resolving it with new URL(path, origin). Resolved, a path starting with //evil.test/ becomes a URL on evil.test, and a form on another site would pass the check.

Vercel

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { vercel } from '@k8ordo/framework/vercel';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [framework({ mode: 'server' }), vercel()],
});

Add vercel() from @k8ordo/framework/vercel beside framework(), and vite build also writes .vercel/output/ in the shape of Vercel’s Build Output API (v3). vercel deploy --prebuilt deploys it as it is.

The client build becomes static files on Vercel’s CDN, and every request that matches no file goes to one Node.js function, which is the request handler. The copies compressed for serve() are left out.

A Vercel function holds nothing but its own directory. The handler has every dependency bundled in, so it becomes the function as it is. A dependency left out with resolve.external is not in the function, and does not work on Vercel.

Tabs opened before a deploy

A tab that stays open keeps running the script it loaded. The payloads it fetches on navigation come from the current deploy, and may include a Client Component that script does not have. Every payload records the URL of the client script it was rendered for.

A payload that does not match the tab’s script is not rendered. The same URL is loaded as a document instead, and the visitor sees the page rendered by the new script rather than error.tsx. The script’s URL includes a hash of everything it loads. After a deploy that changed nothing the browser runs, open tabs keep navigating as before.

In server mode, the same goes for a Server Action’s response: one that does not match the script is not applied, and the page is loaded again.

Serving under a subpath

vite.config.ts
import { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  base: '/docs/',
  plugins: [framework({ mode: 'static' })],
});

To serve below the root of the origin, such as https://example.com/docs/, set that path as Vite’s base; nothing else in the application changes. routes/products/page.tsx is /products in the route table and /docs/products in the address bar. Between the two, the base is added or removed as follows.

  • href() and navigateTo(): href() returns the URL with the base, and navigateTo() navigates to it with the base.
  • pathname and usePathname(): the value without the base.
  • Payloads and client files: below the base, as /docs/products/index.rsc and /docs/assets/.
  • A redirect.ts target: written from the root like the route table, and sent with the base in front. A target on another origin is sent as written.
  • A URL outside the base: in server mode the handler returns 404. In either mode the client runtime does not handle the navigation, and the browser loads it as usual.
  • In server mode, redirect() from a Server Action and the location of a Response a guard.ts returns: sent as written, without the base. Both are URLs, so build them with href(). Give a path built some other way its base with withBase() from @k8ordo/framework.

In static mode, pages are written into dist/client/ at their paths in the route table, and the host serves that directory at /docs/. paths takes paths without the base. sitemap.xml lists URLs with it.

In server mode, serve() reads the base the build was made for from dist/rsc/index.js, and serves the client files below it. A host that calls the handler itself passes the URL as the visitor asked for it, base included.

Warning

Pitfall

base has to be a path from the root. With a relative path like ./, or another origin, the build stops with an error that begins k8ordo serves its pages under Vite's base, so base has to be a path from the root.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2