This article is published in English.
Migrating Express APIs to Next.js App Router Route Handlers
Learn how to convert Express routes, middleware, and data patterns to Next.js App Router with Server Components and deployment considerations.
When migrating to Next.js actually makes sense (and when it doesn't)
The Next.js App Router becomes a sensible choice when your project meets at least two of these conditions:
- Initial load speed or search engine visibility is critical and you currently operate a single-page application that fetches data from an Express backend after the page loads.
- Your deployment pipeline already targets Vercel or similar platforms or you are prepared to adopt that infrastructure — the App Router's edge and serverless features assume this hosting environment.
- Your Express application primarily serves pages and handles standard CRUD operations instead of managing persistent connections, background tasks, or compute-intensive processes.
- Your engineering team commits to understanding Server Components thoroughly. This framework does not simply add file-based routing to React. The rendering architecture differs fundamentally, and applying old single-page application patterns will yield an application that performs worse and confuses developers more than the original.
Migration is not advisable when:
- Your backend handles substantial work beyond page rendering — message queues, scheduled tasks, gRPC endpoints, or persistent socket connections. Route handlers in Next.js cannot replace a dedicated backend service; you will probably deploy Next.js as a frontend layer while keeping a separate Express or Node service running behind it.
- You rely heavily on an established Express middleware ecosystem — specialized authentication providers, rate limiting libraries, or observability integrations — that would require complete reimplementation for minimal gain.
- Your application consists mainly of authenticated, interactive dashboard interfaces with limited search engine requirements — the App Router's primary advantages (streaming server rendering, search optimization, static generation) provide little value in this scenario, while the learning investment remains substantial.
A recommended approach: preserve your Express API as the authoritative source for data and business rules, then position Next.js as the rendering and backend-for-frontend layer. Migrate the user interface and read-oriented routes first, leaving write-heavy internal services unchanged. This guide follows that migration strategy.
Mapping Express routes to Next.js Route Handlers
Route Handlers located in app/api/**/route.ts provide the most direct replacement for Express route definitions. The conceptual change: instead of req and res objects, you receive a Request and return a Response (or use NextResponse for additional convenience), and each HTTP method becomes a separate exported function rather than a router.get() method call.
Consider a standard Express route that retrieves and creates 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 corresponding Route Handler implementation:
// 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 });
}
Several details deserve attention when translating Express patterns to Route Handlers:
Dynamic segments use folder-based syntax instead of colon prefixes. An Express route at /api/orders/:id becomes app/api/orders/[id]/route.ts. The parameter arrives as the second argument to your handler function: GET(req, { params }: { params: Promise<{ id: string }> }). Current Next.js versions deliver params as a promise, so you must await it before reading the value.
No per-route middleware chain exists. The requireAuth middleware from Express transforms into either a shared utility function invoked at the start of each handler (as shown in the example above), or preferably into logic within middleware.ts (discussed in a later section) so individual route handlers remain unaware of authentication concerns.
Body parsing requires explicit calls — you write await req.json() rather than relying on express.json(). No automatic parsing happens, which actually improves clarity: you avoid unexpected body-size limits imposed by a global middleware you configured months ago and forgot.
Route Handlers remain ordinary Node or Edge functions. If you validated input with zod in your Express routes, that validation code transfers without modification.
Server Components vs your existing client-rendered React patterns
This aspect catches teams off guard more than any other. In an Express plus React architecture, every component defaults to being a Client Component: it renders in the browser, and when it requires data, it calls your API from useEffect or through a data-fetching library such as 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>
);
}
The App Router inverts this default. Every component is a Server Component unless you specify otherwise, meaning it executes on the server, accesses your database or services directly, and never sends its JavaScript to the client. You skip the API route entirely for data that belongs to the page and query it directly:
// 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 disappears: the component needs no state hooks, no effect hooks, no loading indicators, and no client-side request chains. The server delivers HTML that already contains the data. You add "use client" only when a component requires interactivity: state, effects, event handlers, or 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 guideline for teams: push "use client" as far down the component tree as you can. Keep your page and layout as Server Components; apply the directive only to leaf components that genuinely need interactivity. If you add "use client" to every component by habit (which happens when porting a single-page application without rethinking the architecture), you gain no advantage from the App Router and end up with a worse mental model than your original setup.
Replacing your Express auth middleware with Next.js middleware
In Express, authentication middleware runs for each route inside your Node.js process. Next.js provides middleware.ts, which intercepts requests at the edge layer before they reach any page or handler, serving as the architectural counterpart:
// 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 problems reliably trip up migrating teams:
- By default, middleware runs on the Edge runtime, not Node.js. Any library depending on Node.js core modules—
jsonwebtoken, typical database clients—will break or silently malfunction. Adopt edge-ready alternatives:joseis the conventional choice for JWT operations. Move all database queries into Server Components or Route Handlers where the complete Node.js environment is present; never attempt them in middleware. - Full database session queries are impractical in middleware, unlike Express patterns that might run
SELECT * FROM sessions WHERE id = ?. Confine middleware logic to fast, stateless operations like token signature verification. Defer authorization questions—"can this user view this order?"—to the page component or handler itself, where database access and Node.js APIs are fully available.
Data fetching patterns: Server Components vs your current API-call approach
Most Express plus React architectures follow a predictable sequence: component mounts, fetches from your API, API queries the database, JSON travels back to the browser, component updates. Two network hops—client to server, server to database—deliver information the server already had.
With the App Router, read operations in Server Components compress this into a single hop: the server queries the database and streams HTML containing the result directly to the client, as shown in the earlier OrdersPage code. For write operations, two patterns are idiomatic: Route Handlers when you need a conventional API (for example, a public REST interface) or Server Actions for mutations triggered by your own forms and UI.
Server Actions diverge most sharply from Express conventions. You define a function that runs on the server and invoke it straight from a form without creating any explicit API route:
// 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>
);
}
Choosing between Server Actions and Route Handlers:
A Server Action is an internal function call from your UI to the server; a Route Handler is a proper HTTP endpoint. The distinction matters when you decide which pattern fits a given mutation.
Use a Route Handler when the endpoint must be stable and callable from outside this Next.js application—a mobile client, a third-party integration, or a public API. Route Handlers give you explicit URL paths, HTTP verbs, and a contract you control and version.
Use a Server Action when the mutation originates from your own forms and interactive components. Server Actions require less boilerplate and automatically revalidate cached data via revalidatePath or revalidateTag, eliminating the manual cache-busting you would write after a fetch call. They are not designed for external consumers or backward-compatibility guarantees; treat them as internal remote procedure calls.
The practical test: if you would document the endpoint for someone outside your team, make it a Route Handler. If it exists only to support a button or form in your UI, a Server Action is simpler.
Deployment differences (Vercel vs ECS/EB)
Teams accustomed to deploying Express on ECS or Elastic Beanstalk encounter a fundamental architectural shift with Next.js on Vercel. An Express application runs as a long-lived Node process: one process handles many requests, maintains warm database connections, and exhibits predictable memory and startup characteristics.
Next.js on Vercel deploys pages and Route Handlers as individual serverless or edge functions. Each function cold-starts independently, operates under its own execution limits, and does not share a persistent database connection pool the way a single Express process does. If you instantiate a Prisma client the same way you did in Express, you will exhaust your database connection limit under load because every function invocation can create 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;
This pattern reuses the client across warm invocations instead of creating a fresh instance on every request.
If you deploy Next.js to ECS or Elastic Beanstalk instead of Vercel, the framework operates as a traditional Node server. Run next build followed by next start, or configure the standalone output mode to produce leaner Docker artifacts. You retain the long-running-process advantages, but you forfeit Vercel's automatic edge network, incremental static regeneration on the CDN, and zero-configuration preview deployments. You must configure those features yourself or accept their absence. This is a valid engineering choice, not a compromise; many teams run Next.js on ECS precisely because they already own the infrastructure and prefer not to split hosting across two providers.
Allocate time for two additional migration tasks: environment variables (Next.js requires the NEXT_PUBLIC_ prefix for any variable exposed to the browser—audit every variable you passed to your React build) and build-time versus runtime configuration (values baked into a static export behave differently from values read at request time, unlike a single Express process where everything is runtime).
Migration checklist and common pitfalls
Proceed through these steps in sequence:
- Deploy the Next.js application in parallel with your Express server—leave the existing application untouched during initial setup.
- Convert read-heavy, search-engine-optimized pages first—these pages gain the most from Server Components and carry minimal migration risk.
- Implement authentication in
middleware.tswith a token-verification library compatible with the edge runtime. - Transform GET endpoints into Server Components that fetch data directly when that data serves only your own pages, bypassing the need for Route Handlers.
- Convert mutation endpoints into Server Actions if they respond exclusively to your own interface; preserve Route Handlers for any endpoint consumed by external clients.
- Resolve your database connection approach for serverless execution before migrating any write operation—this step prevents production outages, not a refinement you can defer.
- Adjust deployment and continuous-integration pipelines last, after the application runs correctly in both development and a production build (
next build && next start)—next devmasks certain errors that surface only in production mode, such as violations of the Server/Client boundary.
Common mistakes encountered during migration:
- Importing server-exclusive code into a Client Component. When a
"use client"component imports anything that accessesfs, your database client, or secrets, the build either fails or—more dangerously—bundles the secret into the client JavaScript. Install theserver-onlypackage to trigger a build error rather than silently leaking credentials. - Omitting
revalidatePathorrevalidateTagafter a Server Action. Without explicit revalidation, the interface displays outdated data following a mutation because Server Components may be cached. - Marking every component with
"use client". Habits carried over from single-page applications are the primary reason a Next.js application fails to outperform the original. - Expecting middleware to support the full Node.js API surface. Middleware runs in the Edge runtime by default—design your authentication checks within that runtime's constraints.
- Skipping load tests of the database connection pool before production deployment. This oversight is the one that triggers alerts in the middle of the night.
Wrapping Up
If you remain uncertain, begin with a narrow experiment: select one read-intensive, SEO-critical page from your current application, rebuild it as a Server Component that queries your database or calls your existing Express API directly, and deploy it at a route your Express server does not handle. Compare time-to-first-byte and bundle size before migrating additional pages. After validating that approach, move authentication into middleware.ts, then convert your highest-traffic mutation workflows to Server Actions one by one—a gradual migration is safer and more practical than a wholesale rewrite. Teams that encounter problems are those that migrate the entire codebase before identifying where Server Components genuinely reduce effort and where they merely introduce a new conceptual layer atop a functioning system.