Deployment
Root.js sites can be deployed in one of two ways:
-
Static (SSG):
root buildrenders every page to HTML ahead of time. Host the output on any static host or CDN. -
Server (SSR): pages are rendered on each request by a Node.js server. Root.js packages the server for App Engine and Firebase Hosting, and it runs anywhere Node.js 24 does.
Test a production build locally
Before deploying, build the site and serve the output locally. root preview shows detailed error pages, and root start runs the production server that SSR deployments use.
# Build the server and client assets, without pre-rendering pages.
pnpm exec root build --ssr-only
# Serve the build at http://localhost:4007.
pnpm exec root preview
Static (SSG)
root build renders every page to dist/html/, along with the client assets and the files in public/:
pnpm exec root build
A page is rendered when its route:
-
has no URL params, e.g.
routes/about.tsx, and doesn't only exporthandle(); or -
has params and exports
getStaticPaths(), which lists the values to render.
Each page is written as index.html in a folder for its URL, e.g. dist/html/about/index.html, and routes/404.tsx is written to dist/html/404.html. With sitemap: true in root.config.ts, the build also writes sitemap.xml.
Then upload dist/html/ to your host. For example, with Firebase Hosting:
// @/firebase.json
{
"hosting": {
"public": "dist/html",
"ignore": ["firebase.json", "**/.*"]
}
}
pnpm exec root build
firebase deploy --only hosting
Large sites can speed up builds with --concurrency and --threads, or rebuild part of a site with --filter, a regex matched against URL paths. See the CLI reference.
Server (SSR) on App Engine
The starter template is set up for App Engine, with a staging and a production app.yaml:
# @/app.prod.yaml
runtime: nodejs24
instance_class: F2
service: default
handlers:
- url: /.*
secure: always
redirect_http_response_code: 301
script: auto
1. Package the site. root create-package builds the site in SSR mode and writes a self-contained app to the output folder: the build, your collections/, the app.yaml, and a package.json with your dependencies and a start script. In a monorepo, dependencies from other workspace packages are included too.
pnpm exec root create-package --target=appengine --out=gae-prod --app-yaml=app.prod.yaml
2. Deploy it. root gae-deploy deploys the app as a new version with gcloud. With --promote, it sends all traffic to the new version once it's deployed. Without it, the version gets its own URL, which is useful for staging.
pnpm exec root gae-deploy gae-prod/ \
--project=my-project \
--promote \
--healthcheck-url=/ \
--max-versions=10
--healthcheck-url checks that the new version responds with a 200 before promoting it, and --max-versions deletes old versions so you stay under App Engine's limits. The starter template wraps both steps in pnpm stage and pnpm deploy.
Environment variables. Set them in env_variables in app.yaml. To keep secrets out of the file, use a '{NAME}' placeholder, and gae-deploy fills it in from the environment it runs in:
# @/app.prod.yaml
env_variables:
SESSION_COOKIE_SECRET: '{SESSION_COOKIE_SECRET}'
CMS scheduled jobs. The CMS needs a job that calls /cms/api/cron.run every minute. On App Engine, add a cron.yaml and deploy it once with gcloud app deploy cron.yaml --project=my-project:
# @/cron.yaml
cron:
- description: CMS scheduled jobs
url: /cms/api/cron.run
schedule: every 1 minutes
Server (SSR) on Firebase Hosting
On Firebase Hosting, static files are served from the CDN and every other request is sent to a Cloud Function that runs the Root.js server. This site, rootjs.dev, is deployed this way.
1. Export the functions. Add an index.ts to the project that exports the server, and the CMS's scheduled jobs if you use the CMS:
// @/index.ts
import {server} from '@blinkk/root/functions';
import {cron} from '@blinkk/root-cms/functions';
export const www = {
server: server({
mode: 'production',
// Options for the Cloud Function, e.g. to keep an instance warm.
httpsOptions: {minInstances: 1},
}),
cron: cron(),
};
Add firebase-functions and firebase-admin to your dependencies, and set "main": "index.js" and "engines": {"node": "24"} in your package.json. These are copied into the packaged function.
2. Configure Firebase Hosting. Serve static files from the packaged build, and rewrite every other request to the server function:
// @/firebase.json
{
"hosting": {
"public": "functions/dist/html",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{"source": "**", "function": "www-server", "pinTag": true}
]
},
"functions": [
{
"source": "functions",
"ignore": ["node_modules", ".git", "*.local"]
}
]
}
3. Package and deploy. root create-package --target=firebase builds the site in SSR mode. When the output folder is named functions, it also compiles index.ts into it.
pnpm exec root create-package --target=firebase --out=functions
firebase deploy --only hosting,functions
Environment variables. Cloud Functions loads a .env file from the functions folder, so copy yours in after packaging, e.g. cp .env functions/. Don't commit it.
Other Node.js hosts
To run the server somewhere else, like Cloud Run or a container, build in SSR mode and start the production server. It listens on the PORT environment variable (default 4007).
pnpm exec root build --ssr-only
pnpm exec root start --host=0.0.0.0
Checklist for sites with the CMS
-
Credentials: the server accesses Firestore with application default credentials. On App Engine and Cloud Functions, give the runtime's service account access to Firestore (e.g. the Cloud Datastore User role).
-
Sign-in: add your production domain to the authorized domains under Authentication in the Firebase console.
-
Session cookies: set
server.sessionCookieSecretfrom a secret, not a value in source control. -
Scheduled jobs: schedule
/cms/api/cron.runas shown above, or scheduled publishing and version history won't run. See Scheduled jobs. -
Secrets: to share secrets between developers and CI, see
root secrets.