
Every few months a team on my Slack asks the same question: “Should we move our React + Express app to Next.js?” Usually it’s triggered by something specific — SEO is tanking because everything renders client-side, the Express server has turned into a junk drawer of ad-hoc routes, or someone read about Server Actions and got excited. The honest answer is: sometimes yes, often not yet, and almost never “rewrite everything this sprint.”
I’ve done this migration on three production codebases now — one clean rewrite, two incremental strangler-fig migrations that ran Express and Next.js side by side for months. This is the guide I wish I’d had before the first one. We’ll go route by route: what maps directly, what needs a mental model shift, and where teams actually lose time.
When migrating to Next.js actually makes sense (and when it doesn’t)
Next.js App Router is a good fit when at least two of these are true:
- SEO or first-paint performance matters and your current app is a client-rendered SPA hitting an Express API.
- You’re already deploying to Vercel-adjacent infrastructure or you’re fine adopting it — App Router’s edge/serverless story is built around that deployment model.
- Your Express layer is mostly “render pages + CRUD JSON endpoints” rather than long-running jobs, WebSocket servers, or heavy background processing.
- Your team is willing to learn Server Components properly. This is not “React with file-based routing.” The rendering model is genuinely different, and treating it like your old SPA will produce a slower, more confusing app than what you started with.
It does not make sense when:
- Your backend does heavy lifting unrelated to rendering — queues, cron jobs, gRPC services, long-lived socket connections. Next.js route handlers are not a replacement for a real backend service; you’ll likely end up running Next.js in front of a slimmed-down Express/Node service anyway.
- You’re deeply invested in an Express middleware stack (custom auth providers, rate limiters, observability hooks) that would need a full rewrite for marginal benefit.
- Your app is mostly authenticated, dashboard-style, client-interactive UI with little need for SEO — the App Router’s biggest wins (streaming SSR, SEO, static generation) don’t move the needle there, and you’ll pay a real learning-curve tax for not much.
A pattern I recommend: keep your Express API as the system of record for data and business logic, and put Next.js in front of it as the rendering/BFF layer. You migrate the frontend and the “read” routes first, and leave write-heavy internal services alone. That’s the migration path this article walks through.
Mapping Express routes to Next.js Route Handlers
Route Handlers (app/api/**/route.ts) are the closest 1:1 replacement for Express routes. The mental model shift: no req, res — you get a Request and return a Response (or use NextResponse for convenience), and each HTTP verb is its own exported function instead of a router.get() call.
Here’s a typical Express route for fetching and creating orders:
// express: routes/orders.ts
import { Router } from "express";
import { db } from "../db";
import { requireAuth } from "../middleware/auth";
const router = Router();
router.get("/api/orders", requireAuth, async (req, res) => {
const userId = req.user.id;
const orders = await db.order.findMany({ where: { userId } });
res.json({ orders });
});router.post("/api/orders", requireAuth, async (req, res) => {
const { items } = req.body;
if (!items?.length) {
return res.status(400).json({ error: "items required" });
}
const order = await db.order.create({
data: { userId: req.user.id, items },
});
res.status(201).json({ order });
});export default router;
The equivalent Route Handler:
// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
export async function GET(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
} const orders = await db.order.findMany({ where: { userId: user.id } });
return NextResponse.json({ orders });
}export async function POST(req: NextRequest) {
const user = await getSessionUser(req);
if (!user) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}const body = await req.json();
if (!body.items?.length) {
return NextResponse.json({ error: "items required" }, { status: 400 });
}
const order = await db.order.create({
data: { userId: user.id, items: body.items },
});
return NextResponse.json({ order }, { status: 201 });
}A few things worth calling out:
- Dynamic segments like /api/orders/:id become folders: app/api/orders/[id]/route.ts, with the param available as the second argument: GET(req, { params }: { params: Promise<{ id: string }> }) (params is a promise in current Next.js versions — await params before using it).
- No global middleware chain per-route. requireAuth as an Express middleware becomes either a shared helper function you call at the top of every handler (shown above), or better, logic centralized in middleware.ts (covered below) so route handlers don't need to think about auth at all.
- Body parsing is manual — await req.json() instead of express.json(). There's no automatic parsing, which is honestly nicer: no surprise body-size limits from a global middleware you forgot about.
- Route Handlers are still just Node/Edge functions — if you were doing input validation with zod in Express, that code ports over unchanged.
Server Components vs your existing client-rendered React patterns

This is the part people underestimate. In your Express + React setup, every component is a Client Component by default — it renders in the browser, and if it needs data, it calls your API from useEffect or a data-fetching library like React Query.
// old pattern: client-rendered React talking to Express
function OrderList() {
const [orders, setOrders] = useState<Order[] | null>(null);
useEffect(() => {
fetch("/api/orders", { credentials: "include" })
.then((r) => r.json())
.then((data) => setOrders(data.orders));
}, []);if (!orders) return <Spinner />;
return (
<ul>
{orders.map((o) => (
<li key={o.id}>{o.id} — ${o.total}</li>
))}
</ul>
);
}
In the App Router, every component is a Server Component by default, meaning it runs on the server, has direct access to your database/services, and never ships its JS to the client. You don’t fetch through an API route at all for data your page owns — you just query it:
// app/orders/page.tsx — Server Component, no "use client"
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function OrdersPage() {
const user = await getSessionUser();
if (!user) redirect("/login");// direct DB access, no fetch, no loading state, no client bundle cost
const orders = await db.order.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
});
return (
<ul>
{orders.map((o) => (
<li key={o.id}>{o.id} — ${o.total}</li>
))}
</ul>
);
}
Notice what’s gone: no useState, no useEffect, no loading spinner, no client-side fetch waterfall. The HTML arrives already populated. You only reach for "use client" when a component needs interactivity — state, effects, event handlers, browser-only APIs:
// app/orders/OrderFilters.tsx
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";export function OrderFilters() {
const router = useRouter();
const params = useSearchParams();
const [status, setStatus] = useState(params.get("status") ?? "all"); function apply(next: string) {
setStatus(next);
const url = new URLSearchParams(params);
url.set("status", next);
router.push(`/orders?${url.toString()}`);
}return (
<select value={status} onChange={(e) => apply(e.target.value)}>
<option value="all">All</option>
<option value="pending">Pending</option>
<option value="shipped">Shipped</option>
</select>
);
}
The practical rule I give teams: push "use client" as far down the tree as possible. Your page and layout stay Server Components; only the leaf components that actually need interactivity get the directive. If you slap "use client" on every component out of habit (which is what porting an SPA verbatim tends to produce), you get zero benefit from the App Router and a worse mental model than what you had.
Replacing your Express auth middleware with Next.js middleware
Express auth middleware runs per-route inside the Node process. Next.js middleware.ts runs at the edge, before a request even reaches a page or route handler, and it's the closest architectural analog:
// old: middleware/auth.ts (Express)
import jwt from "jsonwebtoken";
export function requireAuth(req, res, next) {
const token = req.cookies.session;
if (!token) return res.status(401).json({ error: "unauthorized" }); try {
req.user = jwt.verify(token, process.env.JWT_SECRET!);
next();
} catch {
res.status(401).json({ error: "invalid token" });
}
}// middleware.ts — lives at the project root
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose"; // edge-compatible, unlike jsonwebtoken
const PROTECTED_PREFIXES = ["/dashboard", "/orders", "/api/orders"];
export async function middleware(req: NextRequest) {
const isProtected = PROTECTED_PREFIXES.some((p) =>
req.nextUrl.pathname.startsWith(p)
);
if (!isProtected) return NextResponse.next(); const token = req.cookies.get("session")?.value;
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
} try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
const { payload } = await jwtVerify(token, secret);// forward the verified user id downstream via a request header
const headers = new Headers(req.headers);
headers.set("x-user-id", String(payload.sub));
return NextResponse.next({ request: { headers } });
} catch {
return NextResponse.redirect(new URL("/login", req.url));
}
}
export const config = {
matcher: ["/dashboard/:path*", "/orders/:path*", "/api/orders/:path*"],
};Two gotchas that bite people every time:
- Middleware runs on the Edge runtime by default, which is not Node.js. Libraries that depend on Node built-ins (jsonwebtoken, most DB drivers) will fail silently or throw. Use edge-compatible alternatives (jose for JWT verification is the standard swap) and keep any real DB lookups inside your Server Components or Route Handlers, not middleware.
- Middleware can’t easily do a full DB session lookup the way an Express middleware might (SELECT * FROM sessions WHERE id = ?). Keep it to cheap, stateless checks — verifying a signed token — and do the "does this user still have access to this resource" check inside the actual page or route handler where you have full Node runtime access.
Data fetching patterns: Server Components vs your current API-call approach

Your Express + React app almost certainly had this shape: React component mounts → calls your API → API queries the DB → JSON back to the browser → component re-renders. That’s two network hops (browser→API, API→DB) for data the server had the whole time.
In the App Router, for reads, Server Components collapse that to one hop — server to DB, HTML streamed straight to the browser, as shown in the OrdersPage example above. For writes, you have two idiomatic options: Route Handlers (for cases you still want a "real" API, like a public REST endpoint) or Server Actions (for form/mutation flows initiated from your own UI).
Server Actions are the biggest departure from the Express mental model — you write a server-side function and call it directly from a form, with no manually-wired API endpoint at all:
// app/orders/actions.ts
"use server";
import { db } from "@/lib/db";
import { getSessionUser } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";export async function createOrder(formData: FormData) {
const user = await getSessionUser();
if (!user) redirect("/login"); const itemId = formData.get("itemId");
if (typeof itemId !== "string" || !itemId) {
throw new Error("itemId is required");
} await db.order.create({
data: { userId: user.id, items: [{ itemId, qty: 1 }] },
});// re-render the orders page with fresh server data — no client refetch needed
revalidatePath("/orders");
}
// app/orders/NewOrderForm.tsx
import { createOrder } from "./actions";
export function NewOrderForm() {
return (
<form action={createOrder}>
<input type="text" name="itemId" placeholder="Item ID" required />
<button type="submit">Create order</button>
</form>
);
}Server Actions vs Express endpoints — how to decide:

Express endpoint Next.js Server Action Called from fetch() from anywhere Only from your Next.js UI Needs a stable public contract (mobile app, third-party) Yes No — treat as internal RPC Revalidates UI automatically No, manual refetch Yes, via revalidatePath/revalidateTag Versioning / backward compatibility You own it Not designed for it
The rule of thumb: if the endpoint is consumed by something other than this Next.js app (a mobile client, a partner integration, a public API), keep it as a Route Handler with a real contract. If it’s a mutation triggered by your own forms and buttons, a Server Action is less code and gives you automatic revalidation for free.
Deployment differences (Vercel vs ECS/EB)
This is where teams get real surprises. Your Express app on ECS or Elastic Beanstalk is a long-running Node process — one process, handling many requests, holding warm DB connections, with predictable memory and startup behavior.
Next.js on Vercel is fundamentally different: pages and route handlers deploy as individual serverless (or edge) functions. Each one cold-starts independently, has its own execution limits, and — critically — does not share a persistent DB connection pool the way your Express process did. If you lift-and-shift a Prisma client the way you used it in Express, you will exhaust your database’s connection limit under load, because every function invocation can spin up its own connection.
// lib/db.ts — required pattern for serverless Prisma
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };// reuse the client across warm invocations instead of creating a new one each time
export const db =
globalForPrisma.prisma ??
new PrismaClient({
// use a pooled connection string (e.g. PgBouncer / Prisma Accelerate / RDS Proxy)
datasources: { db: { url: process.env.DATABASE_URL_POOLED } },
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
If you’re staying on ECS/EB instead of Vercel, know that self-hosted Next.js runs fine as a standalone Node server (next build && next start, or the output: "standalone" build target for smaller Docker images) — you keep your long-running-process benefits, but you lose Vercel's automatic edge network, ISR-on-CDN behavior, and zero-config preview deployments. You'll need to configure those yourself (or accept you don't get them). This is a legitimate choice, not a downgrade — plenty of teams run Next.js on ECS specifically because they already have the infra and don't want two hosting providers.
Also budget time for: environment variables (Next.js has NEXT_PUBLIC_ prefix rules for anything exposed to the browser — audit every env var you were passing to your React build), and build-time vs runtime config (things baked into a static export vs read at request time behave differently than a single Express process where everything is just runtime).
Migration checklist and common pitfalls
Checklist, roughly in order:
- Stand up the Next.js app alongside Express (don’t touch the old app yet).
- Move read-only, SEO-relevant pages first — these get the biggest win from Server Components and are lowest-risk to migrate.
- Port auth into middleware.ts using an edge-compatible token verification library.
- Migrate GET endpoints to Server Components (direct data access) rather than Route Handlers where the data is only consumed by your own pages.
- Migrate mutation endpoints to Server Actions where they’re only called from your own UI; keep Route Handlers for anything with external consumers.
- Fix your database connection strategy for serverless before you migrate a single write path — this is the pitfall that causes production incidents, not a nice-to-have.
- Update deployment/CI last, once the app works locally in both dev and a production build (next build && next start) — next dev hides some bugs that only show up in production mode (like Server/Client boundary violations).
Pitfalls I’ve personally hit:
- Importing server-only code into a Client Component. If a "use client" component imports something that touches fs, your DB client, or secrets, the build fails or — worse — leaks the secret into the client bundle. Use the server-only package to get a hard build error instead of a silent leak.
- Forgetting revalidatePath/revalidateTag after a Server Action. Without it, the UI shows stale data after a mutation because Server Components can be cached.
- Treating every component as needing "use client". Ported SPA habits are the number one source of "why is this Next.js app not actually faster than our old one."
- Assuming middleware has full Node APIs. It doesn’t, by default — plan your auth checks around the Edge runtime’s limitations.
- Not load-testing the DB connection pool before going live on serverless. This is the one that pages you at 2am.
Wrapping Up
If you’re on the fence, start small: pick one read-heavy, SEO-sensitive page from your existing app, rebuild it as a Server Component hitting your database or your existing Express API directly, and ship it behind a route your Express app doesn’t own yet. Measure the difference in TTFB and bundle size before you commit to migrating anything else. Once that’s proven out, tackle auth via middleware.ts, then move your highest-traffic mutation flows to Server Actions one at a time — you don't need a big-bang rewrite, and honestly, you shouldn't attempt one. The teams that get burned are the ones that migrate everything before understanding where Server Components actually save them work, and where they just add a new mental model on top of an already-working system.
