Non-HTML Pages
Emit XML, RSS, JSON, and plain-text pages alongside your HTML — same script-page model, different output extension and Content-Type.
What this page covers
How script pages (.tsx, .ts, .jsx, .js) can emit non-HTML output (XML sitemaps, RSS feeds,llms.txt, JSON manifests). Covers the filename convention, the frontmatter override, the precedence rule between them, per-pageContent-Type metadata, and what the engine does about stale outputs when the extension changes.
zfb's page model isn't HTML-only. The same script-page file format that renders pages can also produce .xml, .rss, .txt, .json, or anything else you can stringify. The engine picks the output extension, sets the Content-Type for the dev server, and writes the file under dist/ exactly like an HTML page — only the extension changes.
This is one of the six engine primitives — see Engine vs Framework for the full list. Sitemap and feed generation, llms.txt, JSON API endpoints rendered at build time — they all sit on this primitive.
Two ways to declare the extension
Filename convention
The simplest path: name your page file with the target extension embedded as the second-to-last .-separated segment of the filename stem.
pages/sitemap.xml.tsx → /sitemap.xml
pages/feed.rss.tsx → /feed.rss
pages/llms.txt.tsx → /llms.txt
pages/about.tsx → /about/index.htmlThe last segment before the source extension is the output extension; earlier dots are part of the page name. pages/ becomes /. pages/ has no convention hint and defaults to .html.
The convention applies to every script page extension, not just .tsx — .ts, .jsx, and .js behave identically:
pages/sitemap.xml.js → /sitemap.xml
pages/feed.rss.ts → /feed.rss
pages/llms.txt.jsx → /llms.txt.mdx, .md, and .html pages are not covered: they always use the directory-index HTML layout, and a filename hint on them has no effect.
Frontmatter override
Sometimes the filename is fixed and you want a different output. Add a sibling extension literal next to the page's frontmatter:
// pages/raw.tsx
export const frontmatter = { title: "Raw" };
export const extension = "txt";
export default function Raw() {
return "plain text body";
}Output: /.
Both frontmatter and extension are statically extracted by SWC without evaluating the module — see Frontmatter for the literal-only contract.
Precedence
When both mechanisms apply, the rule is:
frontmatter
extension> filename convention >htmldefault
pages/page.html.tsx → .html (filename hint)
pages/page.html.tsx + extension="txt" → .txt (frontmatter override wins)
pages/about.tsx → .html (default)
pages/about.tsx + extension="json" → .json (frontmatter override)The rule is enforced jointly by zfb-content::tsx_frontmatter (extracts the extension literal) and zfb-router::route::Route (parses the filename convention). Callers pass the extracted override to Route::output_filename, which applies the precedence when it constructs the output filename.
Content-Type metadata
Every output extension carries a default Content-Type, threaded through to the dev server so it can set the response header correctly:
| Extension | Default Content-Type |
|---|---|
html, htm | text/html; charset=utf-8 |
xml | application/xml |
rss | application/rss+xml |
atom | application/atom+xml |
json, map | application/json |
webmanifest | application/manifest+json |
txt | text/plain; charset=utf-8 |
css | text/css; charset=utf-8 |
js, mjs, cjs | application/javascript; charset=utf-8 |
wasm | application/wasm |
svg | image/svg+xml |
png, jpg, jpeg, webp, avif, gif, ico | matching image media type |
woff, woff2, ttf, otf, eot | matching font media type |
mp4, webm, mp3, ogg | matching video/audio media type |
pdf | application/pdf |
| unknown extension | application/octet-stream (set contentType explicitly) |
The unknown extension row applies to a file that carries an extension zfb doesn't recognize (e.g. .foo) — it is not the same case as an extensionless route. A route with no extension at all (/, /) is the normal HTML output shape and defaults to text/html; charset=utf-8 directly; it never falls through to the application/octet-stream fallback above.
Override per page with export const contentType = "...":
// pages/feed.xml.tsx
export const frontmatter = { title: "Feed" };
export const contentType = "application/rss+xml";
export default function Feed() {
return /* the RSS body */;
}The override beats the table; the table beats the unknown-extension fallback. Static-file hosts (Cloudflare Workers Static Assets, Netlify, S3) derive the response Content-Type from the file extension instead — the in-engine contentType is purely a dev-server convenience.
Stale-output cleanup across builds
The output extension can change between builds. Today's pages/ builds /; tomorrow you add export const extension = "txt" and it builds /. The engine deletes the previous-build output for that route when the extension changes, so dist/ doesn't accumulate stale files (/ left behind beside the new /).
The cleanup runs as part of the build orchestrator's atomic write phase — see Build Pipeline — and is keyed on the route's stable identity (the source path), not its output filename, so renames don't leak old files either.
Example: an XML sitemap from a content collection
// pages/sitemap.xml.tsx
import { getCollection } from "zfb/content";
export const frontmatter = { title: "Sitemap" };
const SITE = "https://example.com";
export default function Sitemap() {
const posts = getCollection("blog");
const urls = posts.map((p) => `${SITE}/blog/${p.slug}`);
const body =
`<?xml version="1.0" encoding="UTF-8"?>` +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
urls
.map(
(loc) =>
`<url><loc>${loc}</loc></url>`,
)
.join("") +
`</urlset>`;
return body;
}Filename convention picks xml. The engine writes dist/ and tags it application/xml for the dev server. No HTML, no layout, no chrome — the page returns a string and the engine treats it as the body.
Example: llms.txt
// pages/llms.txt.tsx
import { getCollection } from "zfb/content";
export const frontmatter = { title: "llms.txt" };
export default function LlmsTxt() {
const posts = getCollection("blog");
const lines = [
"# My Site",
"",
"## Posts",
"",
...posts.map((p) => `- [${p.data.title}](/blog/${p.slug})`),
];
return lines.join("\n");
}Filename convention picks txt. Output: dist/, served with text/plain; charset=utf-8.
What the engine doesn't do
String-encode JSX automatically for non-HTML outputs. Your page returns the body as a string (or a JSX value the renderer serializes). The engine doesn't attempt to "render JSX as XML" — produce the bytes you want and return them.
Generate sitemaps / feeds /
llms.txtfor you. That's a framework concern. The engine ships the substrate (file output + collections +paths()); your framework or your project assembles the convention.
See also
Frontmatter — the literal-only contract for
extensionandcontentTypeexports.Routing — the static-route side, including the filename convention table.
Build Pipeline — where stale-output cleanup runs in the build sequence.
Engine vs Framework — why non-HTML emission is an engine primitive (not a framework add-on).