zfb
GitHub repository

Type to search...

to open search from anywhere

Static Assets

Created May 12, 2026Updated Jun 22, 2026Takeshi Takatsudo

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

Subdirectories are preserved, but the top-level public/ name is stripped. A request to /img/hero.png resolves to <project_root>/public/img/hero.png in dev and to dist/img/hero.png 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="/logo.svg") 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 in public/ 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. The public/ directory has no URL prefix and no top-level nest_service mount; files appear at the site root directly. (Note: compiled CSS and the islands bundle are served from dist/assets/, but per-route HTML is written to .zfb-build/dev-pages/, not dist/.)

  • zfb buildcopy_public_dir (in crates/zfb/src/commands/build.rs) copies every file under public/ into dist/<rel>, recursively. The static dist/ tree your edge CDN serves is the same shape your browser saw in dev.

That means <img src="/logo.svg"> 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/logo.svg 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/foo.tsx route and a public/foo file with the same URL. zfb resolves this deterministically:

  1. Plugin dev-middleware that claims /foo runs first.

  2. Page cache — the rendered output of pages/foo.tsx wins next.

  3. .zfb-build/dev-pages/ directory — the dev HTML root (per-route files written by the dev pipeline) is checked next; /assets/* (CSS, islands bundle) is served from dist/assets/.

  4. public/ directory — only consulted if all of the above miss.

  5. 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 -a dist/. deploy-root/pj/site/ — placing public assets under dist/<base>/... would produce a double-nested path (deploy-root/pj/site/pj/site/img/logo.svg). Set copyPublicWithBase: false to copy public assets flat to dist/ root instead:

export default defineConfig({
  base: "/pj/site/",
  copyPublicWithBase: false,
});

With this setting public/img/logo.svg lands at dist/img/logo.svg. After cp -a dist/. deploy-root/pj/site/ it arrives at deploy-root/pj/site/img/logo.svg, served at /pj/site/img/logo.svg — 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.txt

  • Web 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 prebuild script) and check the optimised outputs into public/, 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 in public/ skips the bundler entirely — the browser will fetch raw source the runtime cannot execute.

  • Files that need a different Content-Type than 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 .txt through 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.

Revision History

Takeshi TakatsudoCreated: 2026-05-13T03:13:52+09:00Updated: 2026-06-22T14:35:33+09:00