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
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.jsonindex.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 aredirect.ts, which redirects to the target. Noindex.rscsits beside it.404.html:not-found.tsx, rendered.sitemap.xml: written only whensiteis 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 buildk8ordo: wrote 4 routes and 404.html and sitemap.xmlThe 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.htmlfor a URL like/products/1 - Serve
index.rscas it is, with anyContent-Typebut HTML - Return
404.htmlfor 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.
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
<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.tsimport { 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 200When 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.
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.tsimport { 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/:idto/posts/!fallback/index.html/posts/:id/index.rscto/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
vite build
vite previewvite 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
dist/
server.js
package.json
rsc/
index.js
ssr/
client/
assets/
index-1a2b.js
index-1a2b.js.br
index-1a2b.js.gzdist/client/ holds no page HTML.
dist/server.js: the server, started with Node.js.dist/package.json: makes the.jsfiles indist/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 (.brand.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
PORT=8080 HOST=0.0.0.0 node dist/server.jsPut 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.3000by default.HOST: the host to listen on. The defaultlocalhostaccepts connections from the same machine only, so inside a container set it to0.0.0.0. Node listens on one of the addresseslocalhostnames, often only::1, so point a proxy on the same machine atlocalhostrather than127.0.0.1, or setHOST=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.tsimport { 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.jsimport { 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.distby default, resolved from the working directory.port: the port to listen on.3000by default;0lets the system pick a free one.host: the host to listen on. The defaultlocalhostaccepts connections from the same machine only, so inside a container pass0.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 sentimmutableand cached for a year. Any other file isno-cache. - Compression: a file with compressed copies is sent in the one
Accept-Encodingprefers. Pages and payloads are compressed as they stream. - Revalidation: a file’s
ETagis derived from its contents, so a revalidation ends in a304even against another server built from the same source. - Ranges: a
Rangeasking for one span gets a206, which Safari needs before it plays a<video>. - Failure: when the handler throws, the response is a
500with the bodyinternal 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.jsimport 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.
// 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.
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.tsimport { 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.tsimport { 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()andnavigateTo():href()returns the URL with the base, andnavigateTo()navigates to it with the base.pathnameandusePathname(): the value without the base.- Payloads and client files: below the base, as
/docs/products/index.rscand/docs/assets/. - A
redirect.tstarget: 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 thelocationof aResponseaguard.tsreturns: sent as written, without the base. Both are URLs, so build them withhref(). Give a path built some other way its base withwithBase()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.
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.