Skip to main content

Static Export

Every page under app/ is file-enumerable — there are no dynamic/parameterized routes like pets/[id].tsx — so a whole @rapidrest/react app can be crawled once and exported as a plain static site: HTML, CSS, and JS, deployable to any static host (S3, Netlify, GitHub Pages, a CDN) with no server required at request time.

Rather than reimplementing ReactRoute's rendering logic, export boots your real server (real DI, real config, real @ReactServices) and crawls it over real HTTP, so the exported output can never diverge from what a live deployment actually serves.

Writing an export script

Write a small export entry script — the static-export analog of your src/server.ts — using runStaticExport():

// src/export.ts
import { Logger } from '@rapidrest/core';
import { ObjectFactory } from '@rapidrest/service-core';
import { runStaticExport } from '@rapidrest/react';
import config from './config.js';

const logger = Logger();
const objectFactory = new ObjectFactory(config, logger);

const result = await runStaticExport(
{ config, basePath: '.', logger, objectFactory },
{ appDir: 'app', routePrefix: '/app', outDir: 'dist/export' }
);
await objectFactory.destroy();

if (result.errors.length > 0) {
console.error(`[export] Completed with ${result.errors.length} error(s).`);
process.exit(1);
}
console.log(`[export] Wrote ${result.pages.length} page(s) to dist/export.`);

runStaticExport(serverOptions, exportOptions) starts a real Server instance from serverOptions (the same shape you pass to new Server(...) in src/server.ts), crawls it with exportStaticSite(), and stops it again. It doesn't destroy serverOptions.objectFactory — you created it, so you own tearing it down (as in the example above).

Export options

export interface StaticExportOptions {
host?: string; // default: "127.0.0.1"
appDir?: string; // default: "app" — ignored when `apps` is given
routePrefix?: string; // default: "" — ignored when `apps` is given
paths?: string[]; // extra routes to crawl — ignored when `apps` is given
exclude?: (string | RegExp)[]; // routes to skip — ignored when `apps` is given
apps?: StaticExportApp[]; // multi-app crawl — see below
outDir?: string; // default: "dist/export"
assetsDir?: string; // default: "dist/public"
notFound?: boolean; // default: true
concurrency?: number; // default: 5
}

(runStaticExport() takes this same shape minus port — it supplies port itself from the server it starts.)

  • appDir — which app to discover routes from, matching the same file convention ReactRoute.resolveAppFile() and createViteConfig use (top-level .tsx files, plus index.tsx inside subdirectories). Ignored when apps is given.
  • routePrefix — the URL prefix the React route is mounted at (e.g. "/app"). It's prepended only to the fetch URL used to crawl each page — discovered and explicit route paths themselves are always prefix-free. Ignored when apps is given.
  • paths — extra prefix-free route paths to crawl beyond what appDir discovers, for pages served from an unconventional file layout that auto-discovery can't find.
  • exclude — route paths (exact strings or RegExp) to skip entirely, e.g. auth-gated or personalized pages that shouldn't be baked into a public export.
  • outDir — where the exported site is written. Cleaned (removed and recreated) exactly once per export run, regardless of how many apps are crawled. The export refuses to run if outDir resolves to the current working directory, one of its ancestors, or the filesystem root.
  • assetsDir — the built hydration/static assets directory (i.e. createViteConfig's outDir), copied verbatim into outDir after the crawl.
  • notFound — when true, probes the app's real _404 page and writes it to <outDir>/404.html for static-host fallback routing.
  • concurrency — maximum number of pages crawled at once, across all apps.

Multiple apps

For a multi-app project, pass apps instead of the single-app appDir/routePrefix/paths/exclude fields. Each app's own routePrefix also becomes its output subdirectory, so pages from different apps can't collide in the export:

export interface StaticExportApp {
appDir: string;
routePrefix?: string; // default: ""
paths?: string[];
exclude?: (string | RegExp)[];
}
const result = await runStaticExport(
{ config, basePath: '.', logger, objectFactory },
{
outDir: 'dist/export',
apps: [
{ appDir: 'apps/www', routePrefix: '' },
{ appDir: 'apps/admin', routePrefix: '/admin' },
],
}
);
// -> dist/export/index.html, dist/export/admin/index.html, ...

The first app in the apps list is authoritative for the site-wide 404.html fallback, matching how a static host only ever looks for one root-level 404 page regardless of app count.

The rapidreact export CLI

npx rapidreact export

This builds the client bundle (vite build) and then runs src/export.ts (or src/export.tsx) with tsx under NODE_ENV=production, writing dist/export/index.html, dist/export/pets/index.html, etc. — the trailing-slash/index.html convention, which works with any static file server — plus dist/export/404.html, and a copy of dist/public (the built hydration assets) into the export root.

Known limitations

  • Route discovery uses the same file convention as hydration entry points (top-level .tsx files, plus nested index.tsx). A page served from an unconventional nested non-index file won't be auto-discovered — pass it explicitly via paths.
  • Hydration asset URLs are always root-absolute, so the exported site only works correctly when served from / — the same pre-existing constraint hydrate already has in a live deployment.
  • Props are frozen at export time (like Next.js's static export): pages whose fetchProps/@ReactService depend on per-request or authenticated state will bake in whatever an unauthenticated crawl request renders. Use exclude to skip personalized pages entirely.
  • There is no support for dynamic/parameterized routes (pets/[id].tsx) — ReactRoute doesn't have that concept today, so there's nothing to enumerate.