# Ship a Site (/docs/start/ship)

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

**Using an agent? Paste this prompt**

```text
Read https://next.mdxui.dev/docs/start/ship.md.
Run pnpm add @mdxui/site@7.0.0-alpha.0, then save ship.ts, site.ts and write-a-theme.ts as the page shows them.
Put every record of my Site in pages/, with home.json, 404.json and one Theme for every record.
Run node ship.ts. Fix each refusal at its path by shortening the content.
Deploy dist/ and confirm an unknown address answers 404 with my 404 page.
```

Copy a five-line prompt: your agent saves the files for the Node script target, builds your records into `dist/` and checks the 404.

Pick where the Site ships. For pages inside an existing React or Next.js app, see [Use it in a React app](https://next.mdxui.dev/docs/start/guides/react-app). Every target below runs in this site's checks, on published exports only, and each file pane shows the whole file, imports included.

**Spec: site().**

`site` builds a whole Site in one call: every record under one Theme, the 404, `sitemap.xml` and `robots.txt`. It is not in `@mdxui/site@7.0.0-alpha.0`; each target below does its work in one file.

## Prerequisites

- Node.js 22.18 or later, and `@mdxui/site` installed as in the [Quickstart](https://next.mdxui.dev/docs/start/quickstart).
- Records in `pages/`: `home.json`, a `404.json`, and one Theme across all of them. The 10 records of [Harbor tides](https://next.mdxui.dev/sites/harbor/records.zip) are a whole Site to start from; the zip unzips to `pages/`.
- Or take every file of the Node script target at once: [ship-starter.zip](https://next.mdxui.dev/ship-starter.zip) holds `package.json`, `ship.ts` and Harbor tides' `pages/`. Run `pnpm install && node ship.ts`.

**File: ship.ts**

```ts
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { PageSchema, renderPage, type Page } from '@mdxui/site';

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));
```

**File: records.ts**

```ts
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { PageSchema, type Page } from '@mdxui/site';

/** 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`);
```

**File: ship.ts · whole**

```ts
// 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`);
```

**File: ship.ts · write**

```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`);
```

**File: vite-plugin.ts**

```ts
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { renderPage } from '@mdxui/site';
import type { Plugin, ResolvedConfig } from 'vite';
import { fileFor, readRecords } from './records.ts';

/**
 * 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));
      }
    },
  };
}
```

**File: react-router.ts**

```ts
import { access, mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { renderPage } from '@mdxui/site';
import { readRecords } from './records.ts';

/**
 * 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'));
```

**File: wrangler.jsonc**

```jsonc
{
  "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" }
}
```

**File: worker.ts**

```ts
// 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);
  },
};
```

**File: Result**

The result, as this site renders it: https://next.mdxui.dev/pages/harbor/404.json

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

## 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`:

```text
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
```

## Render every page

`node ship.ts` writes each page, then `sitemap.xml` and `robots.txt`. Run on the Harbor tides records:

```text
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.txt
```

10 pages, then `sitemap.xml`, which lists 8 of them:

```xml title="dist/sitemap.xml"
<?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](https://next.mdxui.dev/docs/themes/write-a-theme).

## 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`.

## Receive the contact form

[`cta-form`](https://next.mdxui.dev/docs/sections/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:

```text
POST /contact/send
  forwards POST https://forms.harbor-tides.example/inbox: name, email, topic, message
  answers 303 See Other, location: /contact/sent
```

The worked Sites on these docs are served as static files, so their forms do not send.

## 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/](https://next.mdxui.dev/sites/harbor/).

## Next steps

  - [Contact flow and 404](https://next.mdxui.dev/docs/start/guides/contact-and-404): The form, its sent page and every 404.
  - [Site](https://next.mdxui.dev/docs/components/site): Each part of a Site, and what builds it.
  - [Rebrand a Site](https://next.mdxui.dev/docs/start/guides/rebrand): One line moves every page to another Theme.

