Static Assets
How to ship images, SVGs, fonts, favicons, robots.txt, and any other byte-for-byte file through zfb's public/ directory.
What this page covers
How to ship static files — images, SVGs, fonts, favicons, robots.txt, JSON manifests, anything binary — through the public/ directory. Covers the URL convention, the dev/prod parity guarantee, the precedence rule when filenames collide with pages, the interaction with the base mount prefix, and when to reach for a TSX importinstead.
zfb handles non-code assets through a single directory: public/. Drop a file in, reference it by absolute URL, and the same URL works in zfb dev, zfb preview, and the static dist/ your build emits. There's no plugin to install, no import to write, no bundler step you can break.
The convention
Anything inside public/ is served verbatim at the site root. The public segment does not appear in the URL.
public/favicon.ico → /favicon.ico
public/logo.svg → /logo.svg
public/robots.txt → /robots.txt
public/img/hero.png → /img/hero.png
public/fonts/Inter.woff2 → /fonts/Inter.woff2Subdirectories are preserved, but the top-level public/ name is stripped. A request to / resolves to <project_ in dev and to dist/ after zfb build.
Referencing assets
Use absolute URLs. The asset path mirrors what shows up in the rendered HTML:
// pages/index.tsx
export default function Home() {
return (
<main>
<img src="/logo.svg" alt="Site logo" width={128} height={32} />
<link rel="icon" href="/favicon.ico" />
</main>
);
}CSS works the same way — the URL is what the browser ultimately requests:
/* styles/global.css */
.hero {
background-image: url("/img/hero.png");
}
@font-face {
font-family: "Inter";
src: url("/fonts/Inter.woff2") format("woff2");
}Do not import static assets as modules
zfb does not run a bundler over public/. Patterns like the ones below — common in Vite, webpack, and similar toolchains — do not work here:
// ❌ Do not do this for static files.
import logoUrl from "../public/logo.svg";
import heroImg from "./hero.png";There is no asset pipeline that turns those imports into URLs. Use the absolute-URL form (src=) instead. Imports are still the right answer for code — .ts, .tsx, .css modules used by islands — but not for binary files like images, fonts, or SVGs you want the browser to fetch as-is.
If you genuinely need to inline an SVG as JSX (so CSS can style strokes, fills, etc.), copy the SVG markup into a TSX component. That's a code path; public/ is the byte-for-byte path.
Dev / prod parity
The dev server and the production build agree on URL shape. This is a guarantee, not a coincidence:
zfb dev— files inpublic/are served live from disk on each request. The page handler falls back to reading from<public_root>/<path>after a page-cache miss and a<project>/.zfb-build/dev-pages/miss. Thepublic/directory has no URL prefix and no top-levelnest_servicemount; files appear at the site root directly. (Note: compiled CSS and the islands bundle are served fromdist/assets/, but per-route HTML is written to.zfb-build/dev-pages/, notdist/.)zfb build—copy_public_dir(incrates/) copies every file underzfb/ src/ commands/ build. rs public/intodist/<rel>, recursively. The staticdist/tree your edge CDN serves is the same shape your browser saw in dev.
That means <img src= written once in your page works in both modes without conditional logic, environment checks, or a withBase-style helper.
Dev serves from `public/`, not from `dist/`
Only zfb build materializes public/ into dist/. zfb dev does not copypublic/ into dist/ — it reads each requested static file straight frompublic/ on the fly. A consequence worth internalizing: in dev there is nodist/<static-file> to read. If you have tooling that, during development, reaches into dist/ for a file you dropped in public/, it will not find it — point that tooling at public/ instead, or run zfb build first. This is the same serve-direct model zfb has always used in dev; it is spelled out here because it is easy to assume otherwise.
Dev startup and live reload
Two dev-server behaviours follow directly from the serve-direct model, and both are worth knowing because they shape what you can expect while developing.
public/ is not a watch root, so static-asset edits do not live-reload. The dev watcher follows pages/, content/, components/, layouts/, styles/, data/, your config files, and any out-of-tree collection paths — but not public/. Editing, adding, or removing a file under public/ therefore fires no watcher event and triggers no livereload. You do not need one: because the file is served live from disk, the new bytes are already what the next request returns. Reload the page (or re-request the asset) and you see the change.
No automatic reload on static-asset changes
If you change public/ while the dev server is running, the browser tab will not auto-refresh. The change is live on disk immediately, but picking it up requires a manual reload (or a fresh request to the asset URL). This has never been a livereload-triggering edit in zfb, so no project that worked before will break — but if you expected the page to refresh on a public/ save, it does not, and never reliably did.
Boot does not scale with the size of public/. zfb dev binds its listener before walking the project, and public/ is excluded from the walked/watched tree. A large static-asset directory — thousands of images, or a big symlinked tree — no longer delays the moment the server starts accepting connections. This is independence from static-asset / watched-tree size, not from project size in general: the first render, CSS bundling, and the islands bundle still scale with how many pages, islands, and source files you have. For the full boot ordering see Dev mode lifecycle — Boot is bind-first.
Migration: no action required for most projects
The serve-direct model is not new — zfb dev has always served public/ from disk and has never materialized it into dist/ during development. There is no dev-to-dist/ copy step to migrate away from. The only consumer-visible facts to be aware of are the two above: public/ is not a watch root (so static-asset changes do not livereload — they were already no-op events, so nothing that worked before breaks), and dev boot no longer scales with public/ size. The single thing to double-check is tooling that reads a static file out of dist/ during development — in dev that file lives in public/, not dist/. For everything else, no action is required.
Precedence: pages win over public files
It is possible — though usually unintentional — to have a pages/ route and a public/foo file with the same URL. zfb resolves this deterministically:
Plugin dev-middleware that claims
/runs first.foo Page cache — the rendered output of
pages/wins next.foo. tsx .zfb-build/dev-pages/directory — the dev HTML root (per-route files written by the dev pipeline) is checked next;/(CSS, islands bundle) is served fromassets/ * dist/assets/.public/directory — only consulted if all of the above miss.404 otherwise.
So a same-named TSX page always shadows a public file. The reverse is not possible — public/foo cannot override a route. If you need a static file at a URL that a page also claims, rename one of them.
Interaction with base
When zfb.config.ts sets a base prefix (e.g. base: "/pj/site/" for a deploy under a sub-path), files in public/ move under that prefix too:
config: base: "/pj/site/"
public/logo.svg → /pj/site/logo.svg (dev and prod)Both the dev server's serve_page fallback and the build-time copy_public_dir honour the prefix. As long as you write asset URLs in HTML the same way the rest of your project does — typically by going through the link rewriter that the markdown / TSX pipeline already runs — the prefix is applied for free.
Configuration
The directory is configurable. Add publicDir to zfb.config.ts to point somewhere other than the default:
// zfb.config.ts
import { defineConfig } from "@takazudo/zfb/config";
export default defineConfig({
publicDir: "static",
});Default: "public". The path is resolved relative to the project root. A missing directory is a silent no-op — not every project needs one.
Flat copy for deploy-relocation pipelines
When a deploy pipeline relocates the entire dist/ tree into the base sub-path — for example, a workflow that runs cp - — placing public assets under dist/<base>/... would produce a double-nested path (deploy-). Set copyPublicWithBase: false to copy public assets flat to dist/ root instead:
export default defineConfig({
base: "/pj/site/",
copyPublicWithBase: false,
});With this setting public/ lands at dist/. After cp - it arrives at deploy-, served at / — the same URL your pages reference via withBase(), without double-nesting.
zfb preview caveat: with copyPublicWithBase: false, base-prefixed public-asset URLs 404 under zfb preview because the flat copy lives at the dist root and zfb preview does not simulate deploy-side relocation. This is an expected trade-off of the flat-copy scheme. The production deploy is unaffected.
What does NOT go in public/
public/ is the right home for:
Site-wide icons and favicons (
favicon.ico,apple-touch-icon.png)Open Graph / social-share images
robots.txt,humans.txt, security.txtWeb app manifests (
manifest.webmanifest)Fonts you self-host
Decorative imagery referenced by absolute URL from many pages
It is the wrong home for:
Source images you transform (resize, optimise, convert to AVIF/WebP). zfb has no built-in image pipeline; if you need transforms, run them out-of-band (e.g. via a
prebuildscript) and check the optimised outputs intopublic/, or reach for a separate tool entirely.Code dependencies of islands. TSX / JSX / TS / CSS imported by a
"use client"island should live alongside the island and be bundled. Putting code inpublic/skips the bundler entirely — the browser will fetch raw source the runtime cannot execute.Files that need a different
Content-Typethan the extension implies. zfb derives the Content-Type from the file extension. If you need an override, render the file through a TSX page instead (see Non-HTML Pages).
See also
Project structure:
public/— the directory layout at a glance.Non-HTML Pages — render
.xml,.json, or.txtthrough a TSX page when you need control over headers or want the page to depend on collection data.Islands — the path for client-side JS, distinct from the static-asset path described here.