Typing API routes in Next.js is something I’ve dealt with extensively, especially when building scalable applications where type safety can save a lot of debugging time. Next.js API routes are essentially serverless functions that handle HTTP requests, and since Next.js is built on top of Node.js and React, using TypeScript to type these routes properly helps catch errors early and improves developer experience.
Before jumping into the specifics, it’s worth mentioning that typing API routes isn’t just about adding type annotations. It’s about understanding the shape of the request and response objects, knowing what Next.js provides out of the box, and how to extend or customize those types to fit your application’s needs.
Next.js API routes live inside the pages/api directory and act as backend endpoints. Each file exports a default function that receives two parameters:
req - the HTTP request objectres - the HTTP response objectThese objects are based on Node.js’s IncomingMessage and ServerResponse, but Next.js provides its own types to enhance them with some additional helpers.
The official Next.js types for API routes come from the next package:
NextApiRequest — typed request objectNextApiResponse — typed response objectHere’s a minimal example:
import type { NextApiRequest, NextApiResponse } from 'next'
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
res.status(200).json({ message: 'Hello World' })
}
At this point, you have basic typing, but the response type is generic and defaults to any. This means you can improve type safety by specifying the shape of the response data.
One of the most practical ways to improve your API route typing is to specify the response data type using the generic parameter of NextApiResponse. This helps TypeScript know exactly what shape your response will have, which is useful for catching bugs and improving auto-completion.
Example:
type Data = {
name: string
age: number
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Data>
) {
res.status(200).json({ name: 'Alice', age: 30 })
}
This way, if you try to send a response that doesn’t match the Data type, TypeScript will complain.
In real-world APIs, you often handle multiple HTTP methods in the same route. Typing these properly can get tricky because the request body or response might differ depending on the method.
A common pattern is to use a switch or if-else block and narrow types accordingly:
import type { NextApiRequest, NextApiResponse } from 'next'
type User = {
id: string
name: string
}
type ErrorResponse = {
error: string
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<User | ErrorResponse>
) {
if (req.method === 'GET') {
// Fetch user logic here
res.status(200).json({ id: '123', name: 'John Doe' })
} else if (req.method === 'POST') {
// Validate req.body here
const { name } = req.body
if (!name) {
res.status(400).json({ error: 'Name is required' })
return
}
// Create user logic here
res.status(201).json({ id: '124', name })
} else {
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).end(`Method ${req.method} Not Allowed`)
}
}
Here, the response type is a union of success and error shapes, which is a practical way to cover multiple response scenarios.
One of the common pain points is typing the request body (req.body). By default, NextApiRequest types body as any, so you lose type safety there.
Unfortunately, Next.js doesn’t provide a built-in way to type the request body directly, but you can extend the NextApiRequest interface to add your own typing.
Example:
import type { NextApiRequest, NextApiResponse } from 'next'
interface ExtendedNextApiRequest extends NextApiRequest {
body: {
name: string
age: number
}
}
export default function handler(
req: ExtendedNextApiRequest,
res: NextApiResponse
) {
const { name, age } = req.body
// Now TypeScript knows name is string and age is number
res.status(200).json({ message: `Hello, ${name}, age ${age}` })
}
This approach is simple and effective, but it requires you to create a custom interface for each route or group of routes where the request body shape differs.
req.body, better auto-completion, fewer runtime errors.In production, I rarely trust the request body shape just based on TypeScript types because the incoming data is untrusted. I usually combine typing with runtime validation libraries like zod or yup to parse and validate the request body.
Example with zod:
import { z } from 'zod'
import type { NextApiRequest, NextApiResponse } from 'next'
const userSchema = z.object({
name: z.string(),
age: z.number().int().positive(),
})
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST'])
res.status(405).end('Method Not Allowed')
return
}
const parseResult = userSchema.safeParse(req.body)
if (!parseResult.success) {
res.status(400).json({ error: parseResult.error.errors })
return
}
const user = parseResult.data
res.status(200).json({ message: `User ${user.name} is ${user.age} years old.` })
}
This pattern ensures your API route is both type-safe at compile time and validated at runtime, which is critical for security and stability.
req.body as any leads to runtime bugs and poor developer experience.any or unknown: While sometimes necessary, overusing these defeats the purpose of TypeScript.Typing API routes doesn’t directly affect runtime performance since TypeScript types are erased at compile time. However, good typing indirectly improves performance by reducing bugs and improving developer velocity.
From a scalability perspective, well-typed API routes make it easier to maintain and refactor codebases as your app grows. It also helps onboard new developers faster because the contracts between frontend and backend are explicit.
TypeScript typing alone does not guarantee security. You still need to validate and sanitize all incoming data. Typed request bodies can help you write safer code, but they don’t replace runtime checks.
Always combine typing with:
zod, yup)NextApiRequest and NextApiResponse and why typing them matters.NextApiRequest to type req.body and the trade-offs.| Approach | Pros | Cons | Use Case |
|---|---|---|---|
Using NextApiRequest and NextApiResponse as-is |
Simple, minimal setup | Request body is untyped, less safety | Small apps or prototypes |
Extending NextApiRequest to type req.body |
Strong typing on request body | More boilerplate, needs maintenance | Medium to large apps with stable API contracts |
Using runtime validation libraries (e.g., zod) |
Runtime safety + type inference | Extra dependency and parsing overhead | Production apps requiring strict validation |
| Using frameworks like tRPC | End-to-end typesafe APIs, no manual typing | Steeper learning curve, more abstraction | Full-stack apps with tight frontend-backend coupling |
In one project I worked on, we had a Next.js API route handling user profile updates. We extended NextApiRequest to type the request body, used zod for validation, and typed the response with a union of success and error types. This setup caught many bugs early and made the API self-documenting to some extent.
We also standardized error responses across all API routes, which helped frontend developers handle errors consistently. This kind of discipline around typing and validation pays off big in medium to large codebases.
Typing API routes in Next.js is more than just adding type annotations. It’s about creating clear contracts between your frontend and backend, improving maintainability, and reducing runtime errors. Use NextApiRequest and NextApiResponse with generics for response data, extend request types for body typing, and always combine this with runtime validation. Avoid common pitfalls like untyped bodies and unhandled methods, and consider your project’s scale and complexity when choosing your approach.