Wrap
Build and deploy
Building
pnpm build writes build/client (static assets) and build/server (the server-rendering app). Any host that runs Node can serve them with pnpm start, and the included Dockerfile packages the same build for container platforms.
Static hosting
Without a server, React Router builds plain files that any static host can serve.
- SPA mode:
pnpm build:spawrites a singlebuild/client/index.html, and every route renders in the browser. Preview it withpnpm preview:spa. The host has to send unknown URLs toindex.html; on Netlify that's a_redirectsfile containing/* /index.html 200. - Pre-rendered: set
ssr: falseandprerender: trueinreact-router.config.tsto get one HTML file per route, made at build time. That's better for first paint and search.
Either way, build/client is the whole deployable site. There's no index.html source file in the repo: React Router generates it from the Layout in app/root.tsx, so the head tags, fonts and dark-mode script are defined once for every mode. The trade-offs of going serverless:
- No server code runs. Loaders run at build time, and only on pre-rendered routes; in SPA mode only the root route can have one. Actions aren't allowed. Fetch data at runtime with
clientLoaderandclientAction. - Deep links need the host to send unknown URLs to the SPA entry:
index.htmlin SPA mode, or__spa-fallback.htmlwhen pre-rendering with/itself pre-rendered. - Preview with
pnpm preview:spa, a plain static server like your host.pnpm startalso serves the build, but through the Node server, which isn't how it runs in production.
The other route to a static app is React Router's library mode: a plain Vite app with createBrowserRouter and no React Router Vite plugin. That's a different project structure rather than a config change, so with this template, use one of the modes above.
GitHub Pages
This site is deployed exactly this way. It's pre-rendered and served from the project subpath, https://felton.github.io/quiet-on-set/.
-
Config. Set the subpath in both files.
basenamesets the route paths, and Vite'sbasesets the asset URLs.// react-router.config.ts export default { ssr: false, // Every static route, plus /404 for the catch-all route (see step 2). async prerender({ getStaticPaths }) { return [...getStaticPaths(), "/404"]; }, basename: "/quiet-on-set/", } satisfies Config;// vite.config.ts export default defineConfig({ base: "/quiet-on-set/", // ... }); -
Flatten the output. With a
basename, React Router writes pre-rendered pages tobuild/client/quiet-on-set/…, but Pages servesbuild/clientitself at/quiet-on-set/. A short post-build script moves the pages up next to the assets:"build": "react-router build && node scripts/flatten-prerender.mjs"The same script turns the pre-rendered
/404page into404.html, which Pages serves for any unknown URL. That page comes from a catch-all route (route("*", …)), so it also matches the unknown URL in the browser and hydrates without a mismatch. The script isscripts/flatten-prerender.mjson this site's branch. -
Workflow. Build, upload
build/clientas a Pages artifact, and deploy it. Pull requests build but don't deploy. The full file is.github/workflows/docs.yml.- run: pnpm install --frozen-lockfile - run: pnpm build - uses: actions/upload-pages-artifact@v5 with: path: build/client # then, in a job with `environment: github-pages`: - uses: actions/deploy-pages@v5 -
Settings. Under Settings → Pages, set Source to GitHub Actions. If you deploy from a branch other than the default one, also add it under Environments → github-pages → Deployment branches and tags.
Deep links and hard refreshes load real HTML with a 200 status, so there's no need for a
404.html copy of the app. If you don't need pre-rendering, the SPA setup below works too.
SPA mode on Pages
Without pre-rendering, there's no flatten step, but Pages has no rewrite rules, so deep links need a workaround.
- Config. Set
ssr: falseandbasename: "/<repo>/"inreact-router.config.ts, andbase: "/<repo>/"invite.config.ts. - Deep links. Pages serves
404.htmlfor any URL it can't find, so copyindex.htmlthere. Refreshes and shared links then load the app. Pages still sends those responses with a 404 status. That's fine for a personal site, but if search ranking matters, pre-render or use a host with real rewrites. - Workflow. Add
.github/workflows/pages.ymland set Settings → Pages → Source to GitHub Actions:
name: Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: cp build/client/index.html build/client/404.html
- uses: actions/upload-pages-artifact@v5
with:
path: build/client
- id: deployment
uses: actions/deploy-pages@v5Other hosts
The pre-rendered or SPA build/client folder works on any static host (Netlify, Cloudflare Pages, S3, nginx). Deployed at the domain root, it needs no basename and no flatten step. For server rendering, deploy the default build to any Node host or as a container.
Working with agents
The template is set up for coding agents as well as people:
AGENTS.mdlists the commands, the layout and the conventions.CLAUDE.mdimports it..agents/skills/holds the official React Router and shadcn/ui agent skills, symlinked into.claude/skills/.pnpm checkis the bar for done, and CI runs the same command.