Building and serving
zerolith build writes the application into dist/ as static files, so it runs on any host and next to any backend. Serving it well comes down to two rules, one for routing and one for caching:
| Request | Answer |
|---|---|
A file that exists in dist/ |
The file, with its media type |
Any other path in the application, such as /notes/7 |
index.html, so the router renders the route |
| File | Cache |
|---|---|
A file under assets/ |
Cached for a year: Cache-Control: public, max-age=31536000, immutable |
index.html and the other files |
Cache-Control: no-cache, so a new build is picked up at once |
Every file under assets/ has a content hash in its name, so a changed file gets a new name and the old one can be cached forever. index.html keeps its name, so it has to be checked on every visit.
What the build writes
| Path | What it is |
|---|---|
index.html |
The page, loading the hashed script and stylesheet and preloading the chunks they import |
assets/index-<hash>.js |
The application and its packages, minified, with a chunk of its own for each lazy route and compiled locale |
assets/index-<hash>.css |
Every stylesheet the page links and the modules import, combined and minified |
assets/<name>-<hash>.<ext> |
Images, fonts, and other files the code or the stylesheets reference |
Everything from public/ |
Copied as it is, at the same paths |
The command line guide describes the build's options; zerolith preview serves dist/ as a host would, which is the quickest check before deploying.
A path under the domain
An application served under a path, such as https://example.com/app/, names the path once, in zerolith.json next to index.html:
{
"base": "/app/"
}zerolith build then writes every URL under /app/, such as /app/assets/index-<hash>.js, and zerolith dev and zerolith preview serve the application there too, redirecting the site root to it. The router follows the same base when it is given import.meta.env.BASE_URL, as every starter does, so its links and its matching agree with the build:
export const router = createRouter(routes, { base: import.meta.env.BASE_URL });--base /app/ sets the base for one command instead. --base ./ makes the build's URLs relative to the page, for a page opened from disk or embedded at a path that is not known in advance. The router cannot know that path, so such a page should not rely on client-side routes.
A backend next to the dev server
While developing, the application and its backend usually run as two servers, such as zerolith dev on port 8080 and an API on port 3000. The proxy in zerolith.json forwards the backend's paths to it, so the page calls the API on its own origin, with no CORS to configure, and cookies behave as they will in production:
{
"proxy": {
"/api": "http://127.0.0.1:3000",
"/auth": { "target": "http://127.0.0.1:4000", "stripPrefix": true }
}
}A request whose path starts with a prefix goes to that backend with its path and query, the longest prefix first; stripPrefix removes the prefix, so /auth/login reaches the second backend as /login. Request and response bodies stream through, server-sent events included, and WebSocket connections are joined end to end. The backend receives its own address as Host, with the original request in X-Forwarded-Host, X-Forwarded-Proto, and X-Forwarded-For, and a redirect it sends to its own address comes back as a path on the dev server. When the backend is down, the request answers 502 and the terminal says why. zerolith preview forwards the same paths, and --proxy /api=http://127.0.0.1:3000 adds a path for one run. The proxy forwards to http:// addresses only for now.
The fullstack example runs this setup end to end: Harbor, an application under /app/ with a Node backend that answers its API under /api, with a session cookie and a live stream of server-sent events, both through the proxy. zerolith new my-app --example fullstack starts a project of your own from it.
Served by a backend
A backend that already runs in production can serve the build from its static file handler, which keeps the application and its API on one origin. Build into the backend's folder with "build": { "out": "../server/public" } in zerolith.json, or copy dist/ there as a deploy step, and apply the same rules. With the application under /app/, as a function from Request to Response that Node, Deno, Bun, or a worker can run:
import { readFile } from "node:fs/promises";
import { extname, join, normalize, resolve, sep } from "node:path";
const forever = "public, max-age=31536000, immutable";
const types: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".ico": "image/x-icon",
".woff2": "font/woff2",
".txt": "text/plain; charset=utf-8"
};
/**
* Serves a zerolith build from `dist` under `base`: a file that exists is
* sent with its type, the hashed files under assets/ are cached for a year,
* and any other path under the base gets index.html, uncached, so routes
* load directly and a new build is picked up at once.
*/
export async function serveApp(
request: Request,
dist: string,
base: string
): Promise<Response>
{
const { pathname } = new URL(request.url);
if (pathname === "/" || pathname === base.slice(0, -1))
{
return new Response(null, { status: 302, headers: { location: base } });
}
let relative: string;
try
{
relative = normalize(decodeURIComponent(pathname.slice(base.length)));
}
catch
{
return new Response(null, { status: 400 });
}
const folder = resolve(dist);
const file = resolve(folder, relative);
const inside = file === folder || file.startsWith(folder + sep);
if (!pathname.startsWith(base) || !inside)
{
return new Response(null, { status: 404 });
}
if (extname(file) === "")
{
const page = await readFile(join(dist, "index.html"));
return new Response(page, {
headers: {
"content-type": "text/html; charset=utf-8",
"cache-control": "no-cache"
}
});
}
const content = await readFile(file).catch(() => undefined);
if (content === undefined)
{
return new Response(null, { status: 404 });
}
const hashed = relative.startsWith(`assets${sep}`);
return new Response(content, {
headers: {
"content-type": types[extname(file)] ?? "application/octet-stream",
"cache-control": hashed ? forever : "no-cache"
}
});
}The same rules in Express 5, where a wildcard needs a name:
import { extname, sep } from "node:path";
import express from "express";
const forever = "public, max-age=31536000, immutable";
const app = express();
app.use("/app", express.static("dist", {
setHeaders(response, path)
{
const hashed = path.includes(`${sep}assets${sep}`);
response.setHeader("cache-control", hashed ? forever : "no-cache");
}
}));
app.get("/app/{*route}", (request, response, next) =>
{
if (extname(request.path) !== "")
{
next();
return;
}
response.set("cache-control", "no-cache");
response.sendFile("index.html", { root: "dist" });
});And in ASP.NET Core, with the build in wwwroot/app/. MapFallbackToFile matches only paths that name no file, so a missing asset still answers 404:
const string Forever = "public, max-age=31536000, immutable";
var files = new StaticFileOptions
{
OnPrepareResponse = context =>
{
var http = context.Context;
var hashed = http.Request.Path.StartsWithSegments("/app/assets");
http.Response.Headers.CacheControl = hashed ? Forever : "no-cache";
}
};
app.UseStaticFiles(files);
app.MapFallbackToFile("/app/{*path:nonfile}", "app/index.html", files);Any other backend applies the same rules with its own static file handler and a catch-all route for the application's paths.
Web servers
nginx, with the build in /srv/my-app/dist:
server {
listen 80;
root /srv/my-app/dist;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}An API behind the same server is one more block, such as location /api/ { proxy_pass http://127.0.0.1:3000; }.
IIS, with the URL Rewrite module installed and this web.config in public/, so the build copies it next to index.html:
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<staticContent>
<clientCache cacheControlMode="DisableCache" />
</staticContent>
<rewrite>
<rules>
<rule name="Application routes" stopProcessing="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory"
negate="true" />
<add input="{URL}" pattern="^/assets/" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
</rule>
</rules>
</rewrite>
</system.webServer>
<location path="assets">
<system.webServer>
<staticContent>
<clientCache cacheControlMode="UseMaxAge"
cacheControlMaxAge="365.00:00:00" cacheControlCustom="immutable" />
</staticContent>
</system.webServer>
</location>
</configuration>Static hosts
A static host serves the files and needs one setting for the routes. A file the host reads from the deployed site, such as _redirects, goes in public/, so the build copies it to the root of dist/.
| Host | Routes |
|---|---|
| Netlify | public/_redirects with the line /* /index.html 200; a file that exists is still served as it is |
| Vercel | vercel.json at the project root, not in public/, with { "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }; files take precedence over rewrites |
| Cloudflare Pages | Nothing: without a 404.html, Pages serves the root for any path that names no file. Headers go in public/_headers |
| GitHub Pages | Copy index.html to 404.html after the build, since Pages shows 404.html for any page that does not exist. A project site lives under /<repository>/, which is its base |
| A storage bucket behind a CDN | index.html as both the index document and the error document |
Compression and security headers
Hosts and CDNs compress text on the fly; a server of your own should enable gzip or Brotli for scripts, stylesheets, HTML, JSON, and SVG. The build prints each file's gzip size, which is close to what is sent.
zerolith needs neither unsafe-eval nor inline scripts, so a strict policy such as Content-Security-Policy: default-src 'self' works with a built application; add the origins of any API on another domain to connect-src. Components' scoped styles are constructed stylesheets, which the policy's style-src does not restrict, while an inline <style> element is blocked.
Unbundled output
zerolith build --unbundled writes the modules as they are, with the zerolith packages in vendor/ behind an import map with integrity hashes. A third-party package needs an entry in the page's own import map, which the build keeps; it warns of a bare import no entry covers. The same routing rule applies, but no file has a content hash in its name, so every file should be served with no-cache, and the integrity hashes catch a stale file that slips through.