zfb
GitHub repository

Type to search...

to open search from anywhere

Static Assets

Created Jun 24, 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-06-25T05:17:25+09:00Updated: 2026-06-25T05:17:25+09:00

AI Assistant

Ask a question about the documentation.