Build, start, preview
A generated project wires its pnpm scripts to the router CLI subpaths:
pnpm build # SSG + request-time server into dist/
pnpm start # serve dist/; dynamic routes and mutations dispatch to dist/server
pnpm preview # static-only preview; refused when dist/server exists
pnpm dev # Vite dev server
pnpm start serves dist/ statically and, when dist/server/index.js exists, dispatches dynamic routes and mutations to it. Port comes from OPEN_ELEMENT_PORT (falling back to PORT, default 4173) and host from OPEN_ELEMENT_HOST. The same CLI with --mode=preview is static-only and refuses to run when dist/server exists, pointing at start mode instead — use it only for projects without request-time routes.
Static output
pnpm build writes the prerendered site into dist/:
dist/<route>/index.htmlfor every prerendered route, plusdist/404.htmlfor the not-found route.- Everything under
public/copied as-is — favicon,robots.txt, images. dist/client/when the app has islands or enhanced forms: the shared entryclient.jsand one chunk per island tag underdist/client/islands/.dist/island-manifests/page-<hash>.json, one per page, listing each island's tag, chunk URL, strategy and layer — the built answer to "which JavaScript does this page load?".
A project with no islands, no enhanced forms, no actions and no 'dynamic' routes is exactly this directory: upload it to any static host, and no server-side code is involved. Components that need no browser behavior are plain HTML in these files, so the prerendered output stays fully styled and readable without JavaScript.
Request-time server
When any route declares renderIntent: { mode: 'dynamic' } or any page exports an action, the build also emits dist/server/:
index.js— a portablefetch(Request) -> Responsehandler over the same SSR bundle.server-manifest.json— the request-time ('dynamic') routes, with their paths, parameter names and action flags.package.json— marks the directory as ESM.
Hybrid pages (static GET + action) keep their prerendered artifact for GET; their POSTs reach the server by method dispatch. Pure-static builds emit none of these, and the output contract is frozen. Local preview is served by pnpm start (the router start CLI), which runs the same handler on Node's node:http — the only browser-independent way to exercise the request-time path before deploying.
Nitro presets
Nitro is the first-party production deployment adapter, and the mount is a near pass-through over the fetch-native seam: the event's standard Request goes in, the handler's Response comes out. Add a route that forwards everything the static layer does not own:
// server/routes/[...path].ts
import { createOpenElementNitroHandler } from '@openelement/router/nitro-mount';
import openElementServer from '../../dist/server/index.js';
// Catch-all over the Nitro static layer (nitro-public/): prerendered files win,
// everything else — dynamic routes, actions, 404s — reaches the built handler.
export default createOpenElementNitroHandler({
handler: (request, context) =>
openElementServer({ req: request, env: (context?.env ?? {}) as Record<string, string> }),
});
// nitro.config.ts
export default defineNitroConfig({
serverDir: 'server',
preset: 'node-server', // or 'cloudflare_module' for Workers
publicAssets: [{ dir: 'nitro-public' }],
});
The deploy sequence is:
pnpm build- Copy everything in
dist/exceptserver/intonitro-public/.dist/serveris server code, not a public asset, and must never be published as one. - Build the host:
pnpm dlx nitro@3.0.260610-beta build— the version this repository's Nitro proofs pin (NITRO_VERSION);presetselectsnode-serverorcloudflare_module. - Run the output (
.output/server/index.mjsfornode-server) withPORTandHOSTset as your platform requires.
Both presets are proven against real Nitro output: prerendered files, dynamic routes, actions, redirects, 404s and a Nitro cache route rule.
Dev server
pnpm dev runs the Vite dev server; the router dev pipeline serves the generated Hono entry through @hono/vite-dev-server, so routes, loaders and actions execute in dev against the same generated entry the build prerenders and serves.
Verification
Verify the artifact, not the log. After pnpm build:
ls dist/server/tells you whether the build produced a request-time server at all. A pure-static project should show nodist/serverdirectory.pnpm startprints which mode it loaded —request-time server entry loaded (dynamic routes enabled), orno dist/server — static-only preview. That line is the artifact's own answer; if it disagrees with your routes, the build is what to look at.curl -i http://localhost:4173/must answer 200 with the prerendered HTML for a static route.curl -i -X POST -H 'x-openelement-action: true' --data 'message=' http://localhost:4173/your-form-routemust reach the action: a validation failure answers422withapplication/problem+json, a success answers303with aLocationheader.- Request a
'dynamic'route twice and confirm the response body differs from the file on disk indist/— that is the request-time path working.
Smoke script
Steps 3–4 as one copyable block (substitute your form route). It fails on the first wrong status, so it doubles as a deploy gate:
set -e
BASE=http://localhost:4173
test "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/")" = 200
test "$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H 'x-openelement-action: true' --data 'message=' \
"$BASE/your-form-route")" = 422
echo 'artifact answers: static 200, action 422'
See also
- Build and configuration — the build, routes and middleware options that shape the output.
- Security — what the generated handlers enforce at request time.
- Testing — the checks worth running against a build.