zfb
GitHub repository

Type to search...

to open search from anywhere

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.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");
}

CSS imported from a package: relative asset references

The absolute-URL rule above is about CSS you author — your project's global stylesheet and anything it @imports from your own source tree. It does not apply to a stylesheet @imported from an npm package installed into node_modules. A package stylesheet is written against its own directory, so it's normal for it to reference files with a relative url():

/* node_modules/some-ui-kit/dist/styles.css */
@font-face {
  font-family: "Some UI Kit";
  src: url("./files/font.woff2") format("woff2");
}

zfb resolves that reference against the stylesheet's own location, emits the referenced file as a content-hashed asset beside the compiled CSS — {stem}-{hash8}.{ext} — and rewrites the url() to a relative reference pointing at it: ./{stem}-{hash8}.{ext}. The browser-facing URL is consistently /assets/{stem}-{hash8}.{ext}; on disk it lands in dist/assets/ for a zfb build (or your configured outDir, if you've changed it from the default), and in .zfb-build/dev-assets/assets/ while zfb dev is running. Importing the same file twice emits it once. A ?query or #fragment suffix on the original reference is preserved on the rewritten one. This works for any file type a package stylesheet references — fonts, images, anything — not fonts only.

Your own authored CSS is unaffected — including a linked workspace package. The global entry, any project-local @imported stylesheet, and a stylesheet @imported from a pnpm-workspace-linked sibling package (its canonical path resolves outside node_modules, since it's not actually installed there) all keep the absolute-URL contract above unchanged — a relative url() in any of them is passed through untouched, exactly as before. Only a stylesheet whose canonical path resolves inside node_modules is treated as a package import. public/ copying, and data: / absolute / full URLs anywhere, are never touched by this rewrite either. The distinction is by where the stylesheet's canonical path resolves, not by anything you opt into: your CSS keeps working the same way it always has, and a node_modules package's CSS gets asset emission because it has no other way to ship files alongside itself.

When it fails

The build fails — it never exits 0 with a broken stylesheet — when a package stylesheet's relative url() can't be resolved to an emittable file: the referenced path is missing, isn't a regular file, isn't readable, or resolves outside the package's own directory. The error names the package, the source stylesheet, and the reference:

error: cannot emit `url()` asset from an imported package stylesheet
  package:    {name}@{version}
  stylesheet: {canonical source path}
  reference:  url({raw reference})
  reason:     ...

To fix it: reinstall the package (the referenced file may be missing from an incomplete install), or vendor the file into public/ and reference it by absolute URL instead of relying on the package's relative reference.

Attribution comes from the CSS compiler's source map

Which package a url() reference is attributed to is determined by the CSS compiler's own source map. zfb trusts that mapping and fails the build on any inconsistency it can detect. A source-map bug on the compiler side could in principle misattribute a reference — in practice this surfaces as a loud build error, since the file isn't found at the misattributed location. The one case that stays silent is a coincidence: a wrong source directory that happens to contain a file at the same relative path. That's a known, accepted residual limitation — not a gap zfb is unaware of.

`zfb dev` does not watch the referenced asset files

The watcher observes your project's source roots, not the package asset files a url() points at. The stylesheet itself is watched: node_modules sits outside the watch roots, but zfb resolves the CSS entry's @import graph at boot and registers each resolved real path as an extra watch target, so editing a package stylesheet reached through @import does fire a CSS rerun. The files it references with url() get no such registration. Replacing one in place while the dev server is running — dropping a different .woff2 over the one inside node_modules, say — produces no watcher event, so CSS is never recompiled and the served content-hashed companion keeps handing out the old bytes until something else triggers a CSS rebuild. Restart zfb dev, or touch the stylesheet that @imports the package — that one is watched, so saving it recompiles the CSS — to pick the new file up. In practice this rarely bites: a package asset is not a file you hand-edit, and the normal way it changes — pnpm install — is something you would follow with a restart anyway. Keeping arbitrary node_modules asset files out of the watched set is a deliberate scope decision — widening the @import-graph channel to cover every file a url() points at carries real watcher-narrowing risk.

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.

_redirects — redirect/rewrite rules

Drop a public/_redirects file to declare redirect and rewrite rules, following the Cloudflare Workers Static Assets _redirects format (a documented subset of it — see below for exactly what's supported). One rule per line:

# public/_redirects
/old-page /new-page 301
/blog/* /articles/:splat
/blog/:slug /articles/:slug 301
/api/* /api-handler 200
  • source may contain a single splat (*, captured as :splat in the target) and any number of :name placeholder segments — each :name segment (e.g. :slug above) captures exactly one path segment and is reused in target under the same name.

  • status is optional and defaults to 302. 301/302/303/307/308 redirect the client; 200 rewrites — it serves target's content without changing the browser's URL (a lightweight reverse-proxy, same-site targets only).

  • Rules are tried in file order; the first matching source wins. A 200 rewrite resolves once — its target is never re-matched against the rule set (no chaining).

  • Only GET/HEAD requests are evaluated, mirroring real Workers Static Assets (which only ever probes the asset layer — and therefore _redirects — for those two methods). Any other method bypasses _redirects entirely.

  • The request's query string is preserved (appended) on redirects; it is dropped on rewrites, since the client-visible URL never changes there.

  • A malformed line (bad status, missing token, a 200 rule targeting an external URL, …) is skipped with a warning — a broken _redirects file never blocks zfb dev or zfb preview from starting.

Where it runs: zfb dev loads it at boot and live-reloads on every edit to public/_redirects (no full rebuild). zfb preview (static mode) loads it once at boot from the output root — static preview does no rebuild or watch, so a rule change needs a fresh zfb build + restart. Adapter-mode preview (wrangler dev) and a real deployed Worker honour _redirects natively through Workers Static Assets, not through zfb's engine — zfb never even parses the file there. In every mode, a plugin's devMiddleware/previewMiddleware registration for a URL always takes priority over a _redirects rule for that same URL. See CLI Reference — zfb preview for the base-prefix caveat specific to static preview.

Output-root artifact: zfb build copies public/_redirects verbatim to <outdir>/_redirects (the output root, not nested under base even when copyPublicWithBase is on — it's config Cloudflare's platform expects to find at the root, not a servable asset). It is excluded from the ordinary copy_public_dir pass so it never doubles as a plain static file, and /_redirects itself always 404s when requested directly (in both zfb dev and zfb preview).

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

CreatedUpdated