Skip to content
techpotions
Next.js · Express.js · API Architecture · Backend · Full-Stack · Node.jsOctober 22, 202610 min read

Your API Shouldn't Live Inside Next.js Forever

Next.js route handlers are fine for most CRUD, but long-running jobs, websockets, webhook fan‑in, and a shared API for mobile will push you to a split.

Cover illustration for “Your API Shouldn't Live Inside Next.js Forever”

Start with the query you came here with: next.js vs express.js. The debate is rarely about raw capability — both can listen on a port. The real question is where your API should live after the demo stage, and that answer changes with every socket, long‑running job, and mobile client you add. Here’s how we learned that the hard way while building the operational heart of an outbound AI voice product.

Next.js vs Express.js: the architecture truce nobody writes down

Takeaway: Next.js route handlers own CRUD for the UI that renders them. Express (or any separated service) owns everything that doesn’t fit a request‑response cycle — long‑running work, persistent connections, fan‑in from multiple sources, and APIs that serve a mobile app or third‑party webhook without a browser session.

The Medium comparison article stresses that Express is the go‑to for robust backend APIs, while Next.js shines when your React front‑end and API can share a deployment. Netguru’s breakdown adds that hosting Express on AWS or GCP remains the lower‑risk choice for pure API workloads. And the dev.to piece notes Express wins on custom routing and microservice support.

But these comparisons skip the inflection point: the moment route handlers stop being enough isn't when you outgrow Next.js — it's when your product gets operational. For us, that moment arrived inside the AI Calling Agent back office.

When Next.js API routes earn their keep — and when they cost you

Takeaway: Route handlers excel at session‑bound CRUD with natural auth boundaries. They cost you the moment a request needs to outlive a serverless function timeout, hold a socket open, or serve a client that never loads the Next.js front‑end.

The sweet spot for /api/*/route.ts

  • Dashboard data that mirrors the UI tree. If the page displaying a list of call logs fetches from /api/calls, the route handler lives where it’s consumed. The LinkedIn comparison frames this as Next.js replacing light backend needs — and for a single‑surface web app, that holds.
  • Auth that starts in a browser. NextAuth or a clerk middleware wrapping route handlers keeps the user session inside the same request lifecycle. No JWT propagation headaches.
  • Zero‑dev‑ops deployments. npx vercel is unreasonably effective. When both your front‑end and endpoint can run on the Vercel runtime, you ship in seconds.

The cracks that force a separate service

  1. Long‑running jobs. Serverless functions on Vercel cap at 10–60 seconds depending on plan. Processing a batch of AI voice call transcriptions or running campaign analytics doesn't fit. Express on a plain Node container doesn't care.
  2. Persistent connections (WebSockets, LiveKit signaling, Twilio media streams). The AI Calling Agent uses LiveKit for real‑time voice — which means persistent WebRTC signaling that a serverless route handler can't anchor. Express sits comfortably in front of a media server.
  3. Webhook fan‑in from third parties. Twilio call status callbacks, Stripe events, or OpenAI async completions arrive at unpredictable rates and often need idempotency keys. A standalone service can queue, deduplicate, and acknowledge before a Vercel function timeout fires.
  4. Mobile or non‑browser clients. A companion mobile app that hits the same API doesn't carry the Next.js session cookie. Moving auth to a token‑based service API becomes mandatory. Per StaticMania’s analysis, Express handles backend logic while Next.js stays on rendering — and that separation pays off here.

Workload

Keep in Next.js route handler

Move to Express/service

Dashboard CRUD (calls list, CRM contacts, settings)

✅

Only if shared with mobile

Auth session validation (browser)

✅

Token endpoint if mobile appears

AI agent configuration CRUD

✅

—

Transcriptions summarisation (single, instant)

✅

—

Bulk transcription processing (campaign‑level)

—

✅

WebSocket signaling (LiveKit)

—

✅

Twilio webhook ingestion (call status, recording ready)

—

✅

Campaign analytics / reporting jobs

—

✅

Mobile app API endpoints

—

✅

The AI Calling Agent split: when a real product forces your hand

Takeaway: The tipping point isn't roadmap fantasy — it's the day you open a dashboard and realise the questions are operational: which calls connected, what was said, who changed the agent prompt, and your route handlers can't stitch that data fast enough or stay alive long enough to hear the answer.

Outbound voice campaigns are easy to demo and hard to operate. The AI Calling Agent back office proves this: once past a handful of agents, the team needs a unified view of connected calls, full transcriptions, which numbers belong to which campaign, and who on the team is allowed to touch any of it.

Here's what lands where:

  • Next.js on Vercel still serves the Dashboard, Calls list, Transcriptions viewer, AI Agents configuration, CRM, Users, and Settings UI — and their route handlers for form submissions, single‑record mutations, and auth.
  • A separate Express‑based service (container on GCP, in our case) owns:
  • LiveKit signaling for real‑time voice with OpenAI Realtime API over Twilio telephony. Route handlers can't hold websocket upgrades.
  • Twilio webhook ingestion. Call status, recording‑ready POSTs from Twilio need reliable, non‑serverless receipt.
  • Campaign‑level transcription jobs. Taking 200 call transcripts and chunking them for analysis is a background task, not a UI fetch.
  • Token‑based API surface. Same endpoints that power the Next.js UI are available to a future mobile dashboard or a third‑party integration.

Auth boundaries across the split

Takeaway: Browser sessions stay in Next.js; everything else gets short‑lived JWTs issued by the service itself — and you validate in both worlds.

Once a service moves out, auth fractures. The pattern that works:

TypeScript
// Next.js middleware — still protects UI routes
import { authMiddleware } from "@clerk/nextjs";
export default authMiddleware({
  publicRoutes: ["/api/webhooks/twilio"], // let the service ingest
});

// Express service — issues its own tokens for non‑browser clients
import jwt from "jsonwebtoken";
import { expressjwt } from "express-jwt";

app.post("/auth/token", async (req, res) => {
  const { apiKey } = req.body;
  // validate against stored hash
  const token = jwt.sign({ scope: ["campaign:read"] }, process.env.JWT_SECRET, { expiresIn: "1h" });
  res.json({ token });
});

app.use(
  expressjwt({ secret: process.env.JWT_SECRET, algorithms: ["HS256"] }).unless({ path: ["/auth/token", "/webhooks/twilio"] })
);

Browser sessions don't touch the service directly; they go through Next.js route handlers that optionally proxy to the service with a service‑level API key. Mobile clients authenticate against the service's /auth/token and carry the JWT.

Background work: queue early, or lose calls

Takeaway: The moment you have a webhook that can fire while you're processing another, a queue is cheaper than debugging lost data.

Twilio sends a call‑status webhook, and 2 seconds later sends the recording‑ready webhook for the same call. If both hit a serverless function that cold‑starts, you race against the timeout. In Express, we run a BullMQ queue with Redis:

TypeScript
// Express service — webhook ingestion with queue
import { Queue } from "bullmq";
const callQueue = new Queue("call-processing", { connection: redis });

app.post("/webhooks/twilio", async (req, res) => {
  const { CallSid, CallStatus, RecordingUrl } = req.body;
  await callQueue.add("process-call", { CallSid, CallStatus, RecordingUrl }, 
    { jobId: CallSid }); // deduplicate by CallSid
  res.status(200).send("ok") // acknowledge immediately
});

A serverless function can't hold that queue consumer. Express can — and adds retries with backoff, dead‑letter queues for poison messages, and visibility into backlog length. For a campaign with 500 concurrent calls, that visibility stops operational chaos before it starts.

Deployment and the cost of two runtimes

Takeaway: Two runtimes mean two scaling profiles, two observability tools, and an extra $50–200/month — acceptable when the alternative is losing revenue calls.

  • Next.js on Vercel scales per request and bills per function execution. For the AI Calling Agent's dashboard traffic, this is negligible.
  • Express on a GCP Cloud Run or Fly.io container scales per concurrent request and bills per instance‑minute. The minimum is roughly $30–50/month for a service that idles most of the time.

What you truly pay for is operational glue: cross‑service monitoring, distributed tracing if a front‑end call touches the service, and CI/CD that deploys both when the API contract changes. The Netguru piece rightly flags that standalone backends increase complexity — but the cost of missed calls in a voice product dwarfs the cost of a container.

Migration path: from route handlers to a service without a rewrite

Takeaway: Move one endpoint at a time, behind a feature‑flagging proxy, and keep the Next.js route handler as a pass‑through while you migrate.

Here's the sequence we followed, and that any team can:

  1. Identify the boundary endpoints — those with long‑running work, webhook fan‑in, or non‑browser clients. In the AI Calling Agent, this was Twilio webhooks and the LiveKit signaling endpoint.
  2. Spin up a minimal Express service with one /health route and one noise endpoint (e.g., /webhooks/twilio). Deploy to a container environment.
  3. Proxy from Next.js behind a flag.
TypeScript
   // app/api/webhooks/twilio/route.ts
   import { NextResponse } from "next/server";
   const USE_SERVICE = process.env.TWILIO_WEBHOOK_SERVICE === "true";

   export async function POST(req: Request) {
     if (USE_SERVICE) {
       const body = await req.text();
       await fetch(`${process.env.SERVICE_URL}/webhooks/twilio`, {
         method: "POST",
         headers: { "Content-Type": "application/x-www-form-urlencoded" },
         body,
         // include a service‑level API key
       });
       return new NextResponse("ok", { status: 200 });
     }
     // legacy handler code (to be removed after verification)
   }
   
  1. Flip the flag in production for progressively heavier traffic. Monitor the service logs.
  2. Remove the route handler body once the service has handled the full workload for a billing cycle — leaving the Next.js route as a pass‑through if needed, or deleting it entirely to make the service the sole handler.

No rewrite. No database migration. The same Postgres instance serves both runtimes, so data ownership doesn't fracture.

Next.js vs Express.js is a false binary — the real choice is about workload shape

Takeaway: You'll likely run both. Next.js for the UI‑facing surface; Express or a service container for the operational underbelly. The decision is not framework‑against‑framework — it's request‑and‑render workload against listen‑and‑hold workload.

Mirrorfly's comparison says both frameworks are great assets for your project — and that's truer than any either‑or advice. The AI Calling Agent doesn't debate next.js vs express.js internally; it runs both where each fits, and the team spends their cycles on voice campaign operations, not on framework wars.

When you’re building a modern web application that needs to evolve past the demo phase, our web team sets that split boundary early — and when voice, sockets, or mobile api requirements arrive, our api development practice builds the service without breaking the front‑end. For teams who prefer staying within the Vercel ecosystem while still getting a production‑grade service tier, our Next.js development work embeds that split cleanly.

FAQ

Can Next.js API routes fully replace Express?

For purely browser‑driven CRUD, yes — and the deployment simplicity is real. The moment you add webhooks, persistent connections, or a mobile client, you’ll need a service that doesn't live inside a serverless function timeout.

How do I handle auth when I split the backend?

Browser sessions stay in Next.js middleware. The service issues short‑lived JWTs for non‑browser clients. Next.js route handlers proxy to the service using a server‑side API key when they need service‑owned data.

Is a separate service worth the extra hosting cost?

When the alternative is lost webhooks, dropped calls, or a mobile app that can't authenticate, the $50–200/month container cost pays for itself in missing‑data prevention alone. Run the split only for endpoints that genuinely need it — not everything.

Written by
techpotions
All entries
Next.js vs Astro: Pick by How Much of the Page Talks Back
The weekly

One email a week, from the workshop.

What we published, what we shipped, and the free packs as they land. No drip sequence, no webinar, unsubscribe in one click.