Chapter 21 — Production and SSG
You have built Deskflow, tested it, and profiled it. Now you need a build that a static host can serve. This chapter covers the production build pipeline and the optional Static Site Generation (SSG) step that pre-renders public routes into ready-to-deploy HTML.
This chapter is a lab. You will:
- Run
npm run buildand inspect what it produces - Run
npm run build:ssgand confirm which routes were pre-rendered - Understand exactly which routes SSG skips — and why
- Serve the output locally and verify client-side navigation still works
Mental model (30 seconds)
npm run build
↓ version inject → prod compile (esbuild, minified)
↓ static assets copied
↓ strip-dev-blocks removes HMR / DEV MODE artifacts
↓ output: dist/ (the compiled bundle, no SSG yet)
npm run build:ssg (optional, after build)
↓ ssg.mjs reads src/routes/routes.(ts|js)
↓ collects static public routes
↓ spins up a headless Puppeteer browser
↓ visits each route, strips dev DOM, captures HTML
↓ writes _deploy/<route>/index.html
↓ writes _deploy/sitemap.xml
npm run build:full = build then build:ssg (in sequence)
There is no build:bots script — older docs that mention it are obsolete. The SSG script (ssg.mjs) replaced the old bot-build approach.
Build scripts reference
| Script | What it does |
|---|---|
npm run build | Full production compile + dev-strip. Output lands in dist/ |
npm run build:client | Client bundle only, without the dev-strip step. Useful for Capacitor |
npm run build:ssg | node .nativecore/scripts/ssg.mjs --yes — pre-renders public routes |
npm run build:full | build then build:ssg in sequence |
Lab — Run the production build
npm.cmd run build
Watch the output. A successful build ends with no errors and produces a dist/ directory. If build:ssg has run before, _deploy/ will already exist; the build step itself does not populate _deploy/.
Open dist/ and confirm:
- The compiled bundle is present (usually
dist/app.jsor similar) - There is no
denc-toolsorhmrartifact in the output - The
index.htmldoes not contain the DEV MODE pill markup
Challenge — Bronze
- [ ]
npm run buildcompletes with exit code 0 - [ ] Confirm
dist/exists after the build - [ ] Search
dist/for the stringCOMPONENT_BUILDER_ENABLED— it must not appear
SSG — how it decides what to pre-render
ssg.mjs reads your routes file (src/routes/routes.ts or routes.js) as plain text and applies these rules:
Routes that ARE pre-rendered
- Static literal paths:
/,/tasks,/settings - These paths must appear inside
r.register(...)calls in the routes file
Routes that are SKIPPED
| Reason | Example |
|---|---|
Contains :param | /tasks/:id — dynamic, cannot be pre-rendered statically |
Contains * wildcard | /* |
Inside r.group({ middleware: ['auth'] }, ...) | Non-empty middleware array signals a protected group |
The middleware-group detection is text-based: if the middleware array in a r.group(...) call is non-empty, every r.register call nested inside that group is treated as protected and skipped.
// routes.js — these are the groups SSG inspects:
r.group({}, (r) => {
// middleware: [] — empty → eligible for SSG
r.register('/', 'src/views/public/home.html', lazyController('home', ...));
r.register('/tasks', 'src/views/public/tasks.html', lazyController('tasks', ...));
});
r.group({ middleware: ['session'] }, (r) => {
// middleware: ['session'] — non-empty → SSG SKIPS all of these
r.register('/settings', 'src/views/protected/settings.html', ...);
});
Important: An empty
middleware: []group (the scaffold default beforeyou attach tags) is still eligible for SSG. Only add the middleware tag when
the route is genuinely protected.
Legacy protectedRoutes export
If you have an older-style routes file that exports:
export const protectedRoutes = ['/settings', '/dashboard'];
SSG also honors that array and skips those paths.
Lab — Run SSG
Before running SSG, confirm dist/ exists from npm run build. SSG reads the compiled output and Puppeteer-renders it.
npm.cmd run build:ssg
SSG will:
- Detect whether
localhost:3000is already serving the app - Start the app server if not (
node server.js) - Launch a headless Chromium browser via Puppeteer
- Visit each eligible route, capture the rendered HTML
- Strip all dev-tool DOM nodes from the captured HTML
- Write
_deploy/<route>/index.html - Write
_deploy/sitemap.xml - Shut down the server if it started it
Reading the output
After SSG completes, inspect _deploy/:
_deploy/
index.html ← pre-rendered /
tasks/
index.html ← pre-rendered /tasks
sitemap.xml
Open _deploy/tasks/index.html in a text editor. You should see:
- The full rendered HTML of the tasks view (no empty
<div id="main-content">) - The
<script type="module">tag that loadsapp.jsfor hydration - No DEV MODE pill, HMR script, or
nc-denc-controlmarkup data-prerendered-route="/tasks"on the#main-contentelement
Open _deploy/index.html in a browser (via a local server, not file://). The page should display instantly with no white flash, then hydrate and allow client-side navigation.
npx serve _deploy
Visit http://localhost:3000, then navigate to /tasks using an <nc-a> link. The URL changes; the router fires; the page updates — SSG pre-renders but the app still navigates as an SPA after hydration.
Challenge — Silver
- [ ]
_deploy/tasks/index.htmlexists afterbuild:ssg - [ ]
/tasks/:iddoes not appear under_deploy/(skipped because of:id) - [ ]
sitemap.xmllists/and/tasksbut not/tasks/:id - [ ] Navigate
/→/tasks→ back from the served_deploy/and confirm no full reload
Deploy to a static host
Once _deploy/ is ready, point your host at it. The only folder you publish is _deploy/ — never the repo root or a bare dist/.
SPA fallback (required)
Deep links must rewrite to index.html with a 200. The scaffold ships public/_redirects:
/* /index.html 200
Cloudflare Pages and Netlify honor this automatically when it is copied into _deploy/.
Host quick settings
| Host | Build command | Output / publish dir | Notes |
|---|---|---|---|
| Cloudflare Pages | npm run build | _deploy | Most reliable on Pages; add SSG locally/CI if you need pre-render |
| Netlify | npm run build | _deploy | Uses _redirects |
| Vercel | npm run build | _deploy | Add SPA rewrite to /index.html |
| S3 + CloudFront | (CI runs build) | Sync _deploy/ | Error document → index.html |
| GitHub Pages | (CI runs build) | Publish _deploy/ | Needs SPA fallback config |
Cloudflare Pages — step by step
- Push the app repo to GitHub or GitLab.
- Cloudflare → Workers & Pages → Create → connect the repo.
- Set Build command to
npm run buildand Build output directory to - Deploy and hard-refresh a nested route (e.g.
/tasks) to confirm the SPA
_deploy. Use Node 18+.
fallback works.
Prefer npm run build on Cloudflare Pages. build:ssg needs Chromium (Puppeteer); run build:full locally or in a Chrome-capable CI job when you want pre-rendered HTML, then publish that _deploy/.
Direct upload alternative:
npm run build:full
npx wrangler pages deploy _deploy --project-name=your-app-name
Preview before you ship
npx --yes serve _deploy
Confirm client navigation and a hard refresh on a nested path both work.
Capacitor uses _deploy as webDir automatically when you init via the scaffold scripts — see Chapter 23.
For a longer host checklist, see the showcase Deploy guide (/docs/deploy on nativecorejs.com) or docs/DEPLOY.md in the monorepo.
Environment notes
- SSG requires Chromium to be available on the build machine. CI environments
- port 3000 must be free when SSG starts (or already serving the NativeCore
typically need puppeteer with --no-sandbox. The ssg.mjs call already passes --no-sandbox and --disable-setuid-sandbox to the Puppeteer launch.
app). If another process answers on 3000 with JSON, SSG will abort rather than pre-render API responses.
Challenge — Gold
- [ ] Add a
/aboutstatic route to Deskflow (view + controller), run - [ ] Change
/settingsto usemiddleware: ['session'], re-run SSG, and - [ ] Open
_deploy/sitemap.xmland verify the canonical URL is set correctly
build:full, and confirm /about/index.html appears in _deploy/
confirm /settings is now absent from _deploy/
(check index.html for a <link rel="canonical"> tag)
Common mistakes
| Mistake | Fix |
|---|---|
Running build:ssg without build first | SSG needs _deploy/ to exist; run build first or use build:full |
Expecting build:bots to exist | Obsolete — use build:ssg |
Protected routes appearing in _deploy/ | Ensure the group has a non-empty middleware: [...] array |
| Dynamic routes pre-rendered | They cannot be — SSG skips :param and * paths by design |
file:// not loading the app | Static hosts and SPA routing require an HTTP server; use npx serve _deploy |
| Dev pill in the production output | npm run build (not just build:client) runs the strip step |
Verify
- [ ]
npm run buildexits 0 and producesdist/ - [ ]
npm run build:ssgexits 0 and produces_deploy/<routes>/index.html - [ ]
/tasks/:idis absent from_deploy/ - [ ] Protected routes (non-empty middleware group) are absent from
_deploy/ - [ ] Client-side navigation works in the served
_deploy/output
What's next
- Chapter 22 — Realtime helpers — WebSocket and
SSE connection helpers for apps that need live data
Milestone M8 is complete: Deskflow is deployable. The remaining chapters are optional features you add when your app needs them.