React Router v7 is a major milestone: the project merged with Remix, so React Router now offers a full "framework mode" with server-side rendering, data loaders, actions, and a build pipeline — alongside the classic library mode. How you deploy depends on which mode you use. This guide covers both, focusing on framework mode with SSR.
Library mode vs framework mode
- Library mode — React Router as a client-side routing library in a plain Vite/CRA app. You deploy it as a static SPA.
- Framework mode — the Remix-derived full stack: file or config routing,
loader/actionfunctions, SSR, and a server build. You deploy it as a Node server (or to other runtimes via adapters).
If you migrated from Remix or scaffolded with the React Router framework template, you're in framework mode.
Framework mode: the build output
react-router build produces two outputs:
build/client— static client assets (served by a CDN or the server).build/server— the SSR server bundle.
The default server is an Express-based runner via @react-router/serve, or you can bring your own server (Express, Hono) using the request handler.
Running the server
The simplest production server uses the built-in runner:
react-router-serve ./build/server/index.jsreact-router-serve reads the PORT env var and binds appropriately. In a container, ensure it listens on 0.0.0.0 (the runner does this by default) and the injected port.
Loaders, actions, and data
Framework mode's loader and action functions run on the server, making them the place to talk to a database. Link a managed PostgreSQL so DATABASE_URL is injected, then query it in a loader:
// app/routes/users.tsx
import postgres from 'postgres';
import type { Route } from './+types/users';
const sql = postgres(process.env.DATABASE_URL!);
export async function loader() {
const users = await sql`SELECT id, name FROM users LIMIT 20`;
return { users };
}
export default function Users({ loaderData }: Route.ComponentProps) {
return (
<ul>{loaderData.users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
);
}Loaders run only on the server, so the database client and DATABASE_URL never reach the browser bundle. Initialize the client at module scope to reuse the connection pool.
The Dockerfile (framework mode)
# ---- Build ----
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Runtime ----
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/build ./build
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./package.json
EXPOSE 3000
CMD ["npx", "react-router-serve", "./build/server/index.js"]To shrink the runtime image, run npm prune --omit=dev after building, or copy only production dependencies. The client assets in build/client are served by the same server in this setup.
Environment variables and secrets
| Variable | Purpose |
|---|---|
NODE_ENV | production |
PORT | injected listen port |
DATABASE_URL | injected by managed DB link |
SESSION_SECRET | for cookie sessions, if used |
React Router framework mode only exposes env vars to the client if you explicitly pass them through (e.g. via a loader or a root-level config). Server secrets read with process.env in loaders/actions stay server-side.
Static SPA mode (library or SPA mode)
If you don't need SSR, React Router v7 supports an SPA mode: prerender the shell and do client-side routing. Configure SPA mode and build, then deploy the build/client output as a static site to a CDN — no Node server required. This is the cheapest option when all your data fetching happens client-side from a separate API.
// react-router.config.ts
export default { ssr: false } satisfies Config;With ssr: false, the build is a static SPA you can host on any CDN with a SPA fallback.
Health checks
Add a resource route returning 200 for readiness probes:
// app/routes/health.tsx
export function loader() {
return new Response('ok', { status: 200 });
}Point the platform's probe at /health.
Deploying
For framework mode: push, connect the repo, link a managed PostgreSQL, set env vars, and deploy as a container web service. For SPA mode: deploy as a static site. The platform auto-detects React Router, builds, and ships it — live logs surface build errors and confirm the server bound to the injected port.
git push origin mainConclusion
React Router v7 gives you a choice: framework mode for SSR with loaders and actions (deploy as a Node server, read DATABASE_URL server-side) or SPA mode for a static CDN deploy. Pick based on whether you need per-request server rendering and data fetching.
Try it on PandaStack — framework mode with a managed PostgreSQL, or a static SPA, both on the free tier. Connect your repo at [dashboard.pandastack.io](https://dashboard.pandastack.io) and it builds and deploys automatically.
References
- [React Router v7 Documentation](https://reactrouter.com/home)
- [React Router: Deploying](https://reactrouter.com/start/framework/deploying)
- [React Router: Data Loading (loaders)](https://reactrouter.com/start/framework/data-loading)
- [React Router: SPA Mode](https://reactrouter.com/how-to/spa)
- [postgres.js Client](https://github.com/porsager/postgres)