How to build an MCP server for an existing Next.js SaaS
A pragmatic guide to shipping an MCP server on a running Next.js 16 SaaS: packages, routes, auth, session state, and the failure modes you will hit.
This guide takes you from zero to a production-ready Model Context Protocol (MCP) server running inside an existing Next.js 16 SaaS. By the end you will have a dynamic route that speaks Streamable HTTP to Claude, ChatGPT, Cursor, and VS Code, a typed tool that reads your database, OAuth 2.1 on the hot path, Redis-backed session state that survives serverless, and a working registration you can test against today.
We assume the product already ships. No rewrites. The MCP server mounts alongside your existing API routes and reuses the auth, database, and deployment pipeline you already run.
What you need before starting
- Next.js 16.0 or later, App Router.
- Node.js 20 or later.
- A Vercel project with Fluid Compute enabled, or a Node server that can hold open long-running requests.
- Upstash Redis (or any Redis-compatible store) for session coordination across serverless instances.
- An OAuth 2.1 provider. Clerk, Better Auth, Auth.js, and WorkOS all ship MCP-aware flows; rolling your own is fine if you already run one.
Step 1. Install the packages
The Vercel-maintained mcp-handler wraps the official Model Context Protocol TypeScript SDK for Next.js route handlers. Install it alongside the SDK and Zod for tool-input validation.
npm install mcp-handler @modelcontextprotocol/sdk zodThe SDK is the canonical implementation of the protocol. mcp-handler is the thin adapter that turns a server definition into a Next.js route handler, a Nuxt handler, or a SvelteKit endpoint.
Step 2. Create the dynamic transport route
MCP servers speak two transports. Streamable HTTP is the default since the March 2025 specification revision; SSE stays supported for older clients. A single dynamic segment handles both.
Create src/app/api/mcp/[transport]/route.ts:
import { createMcpHandler } from "mcp-handler"
import { z } from "zod"
const handler = createMcpHandler(
(server) => {
server.tool(
"ping",
"Health check, returns pong",
{},
async () => ({ content: [{ type: "text", text: "pong" }] })
)
},
{},
{
basePath: "/api/mcp",
streamableHttpEndpoint: "/mcp",
sseEndpoint: "/sse"
}
)
export { handler as GET, handler as POST, handler as DELETE }Export GET, POST, and DELETE. Streamable HTTP uses POST for tool calls and GET for stream resumption; DELETE ends a session cleanly. Missing any of the three causes silent failures with some clients.
Step 3. Expose a real tool with a typed schema
Tools are the verbs your SaaS hands to the agent. Each one declares a name, a description the model will read, a Zod schema for arguments, and an async function that runs server-side on every call.
server.tool(
"list-invoices",
"Return the most recent invoices for the current workspace. Use when the user asks to see, list, or review invoices.",
{ limit: z.number().int().min(1).max(100).default(25) },
async ({ limit }, { authInfo }) => {
const rows = await db
.from("invoices")
.select("id, number, total, issued_at")
.eq("workspace_id", authInfo.extra.workspaceId)
.order("issued_at", { ascending: false })
.limit(limit)
return { content: [{ type: "text", text: JSON.stringify(rows.data) }] }
}
)Write the description the way a user would prompt it. The model selects tools by reading these strings, not by reading your implementation. Short, specific, verb-led. Declarative schemas let the client validate arguments before they ever reach your code, which cuts failure modes in half.
Step 4. Wire OAuth 2.1
The MCP 2025-11-25 specification requires remote servers to implement OAuth 2.1 with PKCE (SHA-256) and publish a Protected Resource Metadata document (RFC 9728) at /.well-known/oauth-protected-resource. Do not ship a remote MCP server without it. 2025 audits found more than a thousand public MCP endpoints exposed with weak or absent auth.
Wrap the handler in withMcpAuth and a token verifier:
import { withMcpAuth } from "mcp-handler"
import { verifyToken } from "@/lib/auth"
const auth = async (_req: Request, token: string | undefined) => {
if (!token) return undefined
const session = await verifyToken(token)
if (!session) return undefined
return {
token,
clientId: session.clientId,
scopes: session.scopes,
extra: { workspaceId: session.workspaceId, userId: session.userId }
}
}
export const POST = withMcpAuth(handler, auth, { required: true })Clerk ships a drop-in withMcpAuth adapter; Better Auth, Auth.js, and WorkOS each document an MCP flow. If you roll your own, the critical parts are the well-known metadata document and SHA-256 PKCE verification; the specification explicitly forbids plain PKCE.
Step 5. Survive serverless: Redis and Fluid Compute
Streamable HTTP holds an open response while a tool call runs. A default Vercel serverless function will cut it off. Two changes remove the problem.
Enable Fluid Compute on the project. It extends invocation life to 15 minutes and lets a single instance serve concurrent requests, which MCP exploits heavily because most tool calls are I/O-bound.
Add a Redis URL so the handler can coordinate between instances. Without it, a session that starts on one function cannot resume on another, and clients drop mid-call.
const handler = createMcpHandler(
register,
{},
{
basePath: "/api/mcp",
redisUrl: process.env.REDIS_URL,
maxDuration: 900
}
)For production, place Upstash Redis in the same region as your deployment. The handler uses Redis pub/sub between GET and POST calls, so latency here shows up in every tool invocation.
Step 6. Register the server with a client
For Claude Code, add the server to the project's .mcp.json:
{
"mcpServers": {
"your-saas": {
"type": "http",
"url": "https://your-saas.com/api/mcp/mcp"
}
}
}Claude performs the OAuth handshake on first use and caches the token. Cursor, Claude.ai Custom Connectors, and ChatGPT Custom Connectors accept the same URL shape through their settings UI.
Verifying it works
- Send
POST /api/mcp/mcpwith a valid Bearer token and theinitializeJSON-RPC body. You should receive a capabilities object listing your tools. - Run the MCP Inspector with
npx @modelcontextprotocol/inspector. Connect to your URL, authenticate, and invoke every tool. - From Claude Code, type
/mcpand confirm your server appears green. Trigger a tool and inspect the arguments the model sent. - Stress it with 20 concurrent Inspector sessions. Latency should stay flat; if it climbs, the Redis region is wrong or Fluid Compute is off.
Common failures and fixes
- Stream ended unexpectedly. Fluid Compute is disabled, or
maxDurationis below 300 seconds. Set it to 900 and redeploy. - Auth loops on every tool call.
withMcpAuthreturnsundefinedfor a valid token. Usually the token audience or issuer does not match the Protected Resource Metadata you publish. Log both and align them. - Tool descriptions ignored by the model. They are written like code comments instead of user prompts. Rewrite each as a short sentence in the shape "do X when the user asks Y".
- Sessions reset mid-flow.
REDIS_URLis missing in one of your deployment environments. Check the Vercel dashboard; preview and production need the same variable. - Empty capabilities on connect. Only POST was exported. Export GET and DELETE too;
initializecompletes over POST but the client performs GET for stream reads. - Zod validation errors at the client. The tool schema requires a field the model does not know to send. Add a
.default(), or restructure the description so the required field is obvious from natural language.
Going further
Once the server is up, the work shifts from protocol plumbing to tool design. The set of tools you expose becomes part of your product surface area, which is why the curation question matters more than the transport question. See our piece on what MCP is and when a SaaS needs one for the product argument, and our server versus client components decision tree for where the MCP route fits in a Next.js 16 codebase that already relies on server components for data.
Sources
Frequently asked questions
- Do I need to rewrite my Next.js SaaS to add an MCP server?
- No. The MCP server mounts as a dynamic route alongside your existing API routes and reuses the auth, database, and deployment pipeline you already run. The new route lives at /api/mcp/[transport]/route.ts and handles both Streamable HTTP and SSE transports through a single dynamic segment. No rewrites, no parallel infra, no separate deployment. If your SaaS is on Next.js 16 App Router with Node 20+, you can ship the first tool in an afternoon.
- Does an MCP server need OAuth 2.1?
- Yes for remote servers. The MCP 2025-11-25 specification requires OAuth 2.1 with PKCE (SHA-256) and a Protected Resource Metadata document published at /.well-known/oauth-protected-resource. Plain PKCE is explicitly forbidden. 2025 audits found more than a thousand public MCP endpoints with weak or absent auth. Clerk, Better Auth, Auth.js, and WorkOS each ship MCP-aware flows; rolling your own is fine if you already run an OAuth provider, but the well-known metadata document is mandatory either way.
- Why does an MCP server need Redis on Vercel?
- Streamable HTTP holds an open response while a tool call runs. Without Redis, a session that starts on one serverless instance cannot resume on another, and clients drop mid-call. Add Upstash Redis in the same region as the deployment and pass the URL to createMcpHandler({redisUrl}). The handler uses Redis pub/sub between GET and POST calls, so co-region latency shows up in every tool invocation. Pair it with Fluid Compute enabled (15-minute invocation life) for stable production runtime.
- Can one MCP server serve Claude Code, ChatGPT, and Cursor at the same time?
- Yes. MCP is a transport-level protocol, not a vendor protocol. The same URL works for Claude Code's .mcp.json, Claude.ai Custom Connectors, ChatGPT Custom Connectors, Cursor, and VS Code. Each client performs the OAuth handshake on first use and caches the token. The server does not know which client is calling and does not need to. Build the tool surface once, expose it to every MCP-aware agent your users adopt.
- What is the difference between Streamable HTTP and SSE for MCP transports?
- Streamable HTTP is the default since the March 2025 specification revision. It uses POST for tool calls and GET for stream resumption, with DELETE to end a session cleanly. SSE (Server-Sent Events) is the older transport, still supported for legacy clients. A single dynamic Next.js route segment [transport] handles both, so you do not have to pick one. Export GET, POST, and DELETE from the handler. Missing any of the three causes silent failures with some clients.
Studio
Start a project.
One partner for companies, public sector, startups and SaaS. Faster delivery, modern tech, lower costs. One team, one invoice.