Ship a Site
Turn a folder of records into one live Site with its 404 page and a contact flow, from a Node script, Vite, React Router or a Cloudflare Worker.
Pick where the Site ships. Every target below runs in this site's checks, on published exports only.
Prerequisites
- Node.js 22.18 or later, and
@mdxui/siteinstalled as in the Quickstart. - Records in
pages/:home.json, a404.json, and one Theme across all of them. The 10 records of Harbor tides are a whole Site to start from.
Read every record
Each file in pages/ is parsed and given its address: contact/sent.json is /contact/sent. A record that does not parse stops the run at its path.
const origin = 'https://harbor-tides.example'; // the Site's origin, for sitemap.xml
// Every record in pages/: home.json is /, contact/sent.json is /contact/sent.
const pages: { path: string; page: Page }[] = [];
for (const file of (await readdir('pages', { recursive: true })).filter((f) => f.endsWith('.json'))) {
const page = PageSchema.parse(JSON.parse(await readFile(`pages/${file}`, 'utf8')));
pages.push({ path: file === 'home.json' ? '/' : `/${file.replace(/\.json$/, '')}`, page });
}
pages.sort((a, b) => a.path.localeCompare(b.path));Build whole or not at all
Before it writes a file, ship.ts stops on a missing 404 page or a second Theme. Without pages/404.json, then with one record under directory:
Error: pages/404.json is missing: a Site has a 404 page
Error: the records name 2 Themes, quaypoint and directory: a Site has one// A Site renders whole or not at all: a 404 page, and one Theme for every Page.
if (!pages.some((p) => p.path === '/404')) throw new Error('pages/404.json is missing: a Site has a 404 page');
const themes = new Set(pages.map((p) => p.page.theme));
if (themes.size > 1) throw new Error(`the records name ${themes.size} Themes, ${[...themes].join(' and ')}: a Site has one`);Render every page
node ship.ts writes each page, then sitemap.xml and robots.txt. Run on the Harbor tides records:
dist/index.html
dist/404.html
dist/blog.html
dist/blog/tide-windows.html
dist/contact.html
dist/contact/sent.html
dist/integrations.html
dist/integrations/chartplot.html
dist/pricing.html
dist/terms.html
dist/sitemap.xml
dist/robots.txt10 pages, then sitemap.xml, which lists 8 of them:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://harbor-tides.example/</loc></url>
<url><loc>https://harbor-tides.example/blog</loc></url>
<url><loc>https://harbor-tides.example/blog/tide-windows</loc></url>
<url><loc>https://harbor-tides.example/contact</loc></url>
<url><loc>https://harbor-tides.example/integrations</loc></url>
<url><loc>https://harbor-tides.example/integrations/chartplot</loc></url>
<url><loc>https://harbor-tides.example/pricing</loc></url>
<url><loc>https://harbor-tides.example/terms</loc></url>
</urlset>Add mdxuiPages() to plugins in vite.config.ts. vite dev renders a record on each request, so an edit shows on reload, and vite build writes every page beside your entry. A record never takes the address of a file your entry builds.
Run node react-router.ts after react-router build. Every record joins the prerendered client build; a record never replaces a prerendered route. These docs ship their worked Sites this way.
To render under your own Theme, swap renderPage for render from site.ts.
const write = async (file: string, text: string) => {
await mkdir(dirname(file), { recursive: true });
await writeFile(file, text);
console.log(file);
};
for (const { path, page } of pages) await write(`dist${path === '/' ? '/index' : path}.html`, renderPage(page));
// Every page a search engine may list: not the 404, and no record marked noindex.
const listed = pages.filter((p) => p.path !== '/404' && !p.page.meta?.noindex);
await write(
'dist/sitemap.xml',
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${listed.map((p) => ` <url><loc>${origin}${p.path}</loc></url>`).join('\n')}\n</urlset>\n`,
);
await write('dist/robots.txt', `User-agent: *\nAllow: /\nSitemap: ${origin}/sitemap.xml\n`);Serve it, 404 included
Upload the output to a static host whose not-found page is 404.html. On Cloudflare, wrangler.jsonc sets not_found_handling, so any unknown address answers 404 with that page, and runs the Worker only for /contact/send. Deploy with pnpm wrangler deploy.
const write = async (file: string, text: string) => {
await mkdir(dirname(file), { recursive: true });
await writeFile(file, text);
console.log(file);
};
for (const { path, page } of pages) await write(`dist${path === '/' ? '/index' : path}.html`, renderPage(page));
// Every page a search engine may list: not the 404, and no record marked noindex.
const listed = pages.filter((p) => p.path !== '/404' && !p.page.meta?.noindex);
await write(
'dist/sitemap.xml',
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${listed.map((p) => ` <url><loc>${origin}${p.path}</loc></url>`).join('\n')}\n</urlset>\n`,
);
await write('dist/robots.txt', `User-agent: *\nAllow: /\nSitemap: ${origin}/sitemap.xml\n`);Receive the contact form
cta-form posts, and a static host answers only GET. worker.ts takes the post, forwards the fields to CONTACT and redirects to the sent page:
POST /contact/send
forwards POST https://forms.harbor-tides.example/inbox: name, email, topic, message
answers 303 See Other, location: /contact/sentThe worked Sites on these docs are served as static files, so their forms do not send.
// Runs only for POST /contact/send (wrangler.jsonc, assets.run_worker_first); every other path is a file in dist/.
export default {
async fetch(request: Request, env: { CONTACT: string }): Promise<Response> {
if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } });
const form = await request.formData();
// Forward the form, field for field, to the Site's contact address.
const sent = await fetch(env.CONTACT, { method: 'POST', body: form });
if (!sent.ok) return new Response('The message was not sent. Try again.', { status: 502 });
return Response.redirect(new URL('/contact/sent', request.url), 303);
},
};Result
Every record is a page at its address, and any other address answers 404 with your 404 page. The Harbor tides records ship the same way, at /sites/harbor/.
const origin = 'https://harbor-tides.example'; // the Site's origin, for sitemap.xml
// Every record in pages/: home.json is /, contact/sent.json is /contact/sent.
const pages: { path: string; page: Page }[] = [];
for (const file of (await readdir('pages', { recursive: true })).filter((f) => f.endsWith('.json'))) {
const page = PageSchema.parse(JSON.parse(await readFile(`pages/${file}`, 'utf8')));
pages.push({ path: file === 'home.json' ? '/' : `/${file.replace(/\.json$/, '')}`, page });
}
pages.sort((a, b) => a.path.localeCompare(b.path));/** Every record under `dir`, parsed, with its address: home.json is /, contact/sent.json is /contact/sent. */
export async function readRecords(dir = 'pages'): Promise<{ path: string; page: Page }[]> {
const out: { path: string; page: Page }[] = [];
for (const file of (await readdir(dir, { recursive: true })).filter((f) => f.endsWith('.json')).sort()) {
const result = PageSchema.safeParse(JSON.parse(await readFile(join(dir, file), 'utf8')));
if (!result.success) {
const [{ path, message }] = result.error.issues;
throw new Error(`${dir}/${file}: ${path.join('.')}: ${message}`);
}
out.push({ path: file === 'home.json' ? '/' : `/${file.replace(/\.json$/, '')}`, page: result.data });
}
return out;
}
/** The file a static host serves for an address: / is index.html, /pricing is pricing.html. */
export const fileFor = (path: string) => (path === '/' ? 'index.html' : `${path.slice(1)}.html`);// A Site renders whole or not at all: a 404 page, and one Theme for every Page.
if (!pages.some((p) => p.path === '/404')) throw new Error('pages/404.json is missing: a Site has a 404 page');
const themes = new Set(pages.map((p) => p.page.theme));
if (themes.size > 1) throw new Error(`the records name ${themes.size} Themes, ${[...themes].join(' and ')}: a Site has one`);const write = async (file: string, text: string) => {
await mkdir(dirname(file), { recursive: true });
await writeFile(file, text);
console.log(file);
};
for (const { path, page } of pages) await write(`dist${path === '/' ? '/index' : path}.html`, renderPage(page));
// Every page a search engine may list: not the 404, and no record marked noindex.
const listed = pages.filter((p) => p.path !== '/404' && !p.page.meta?.noindex);
await write(
'dist/sitemap.xml',
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${listed.map((p) => ` <url><loc>${origin}${p.path}</loc></url>`).join('\n')}\n</urlset>\n`,
);
await write('dist/robots.txt', `User-agent: *\nAllow: /\nSitemap: ${origin}/sitemap.xml\n`);/**
* Every record in pages/ as a page of a Vite project: rendered on each request
* under `vite dev`, so an edit to a record shows on reload, and written into
* the build's outDir by `vite build`.
*/
export function mdxuiPages({ dir = 'pages' } = {}): Plugin {
let config: ResolvedConfig;
return {
name: 'mdxui-pages',
configResolved(resolved) {
config = resolved;
},
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const path = new URL(req.url ?? '/', 'http://dev').pathname.replace(/(.)\/$/, '$1');
try {
const hit = (await readRecords(join(config.root, dir))).find((r) => r.path === path);
if (!hit) return next();
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(renderPage(hit.page));
} catch (error) {
next(error); // a refused record answers with its path and bound
}
});
},
async writeBundle(_options, bundle) {
const out = resolve(config.root, config.build.outDir);
for (const { path, page } of await readRecords(join(config.root, dir))) {
const file = fileFor(path);
if (file in bundle) throw new Error(`${file} is already in the build: give the record or the entry another address`);
await mkdir(dirname(join(out, file)), { recursive: true });
await writeFile(join(out, file), renderPage(page));
}
},
};
}/**
* Run after `react-router build`: every record in pages/ becomes a file in the
* prerendered client build, beside the routes React Router wrote. A record
* never replaces a prerendered route; the landing record takes index.html,
* the address React Router gives its SPA fallback when / is not prerendered.
*/
export async function renderInto(client = 'build/client', dir = 'pages'): Promise<string[]> {
const written: string[] = [];
for (const { path, page } of await readRecords(dir)) {
const file = path === '/' ? 'index.html' : `${path.slice(1)}/index.html`;
const taken = await access(join(client, file)).then(() => true, () => false);
if (taken && path !== '/') throw new Error(`${file} is a prerendered route: give the record or the route another address`);
await mkdir(dirname(join(client, file)), { recursive: true });
await writeFile(join(client, file), renderPage(page));
written.push(file);
}
return written;
}
if (process.argv[1] === import.meta.filename) console.log((await renderInto()).join('\n'));{
"name": "harbor-site",
"compatibility_date": "2026-10-01",
"main": "worker.ts",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page",
"html_handling": "auto-trailing-slash",
"run_worker_first": ["/contact/send"]
},
"vars": { "CONTACT": "https://forms.harbor-tides.example/inbox" }
}// Runs only for POST /contact/send (wrangler.jsonc, assets.run_worker_first); every other path is a file in dist/.
export default {
async fetch(request: Request, env: { CONTACT: string }): Promise<Response> {
if (request.method !== 'POST') return new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } });
const form = await request.formData();
// Forward the form, field for field, to the Site's contact address.
const sent = await fetch(env.CONTACT, { method: 'POST', body: form });
if (!sent.ok) return new Response('The message was not sent. Try again.', { status: 502 });
return Response.redirect(new URL('/contact/sent', request.url), 303);
},
};