Skip to content
Learni
View all tutorials
Next.js

How to Build a REST API with Next.js in 2026

Lire en français

Introduction

In 2026, Next.js leads fullstack development with its App Router, simplifying high-performance, serverless REST APIs. This tutorial guides you step-by-step to build a complete CRUD API for a todo list, featuring strict Zod validation, precise HTTP error handling, and persistent in-memory storage (via a global module).

Why this approach? It taps into Next.js native optimizations (like implicit Partial Prerendering), avoids unnecessary external dependencies, and sets you up for one-click Vercel deployment. Perfect for modern React apps where frontend and backend live together. At the end, you'll have a tested, scalable, professional API you'll bookmark for real projects. Ready to code?

Prerequisites

  • Node.js 20+ (or 22 LTS recommended for 2026)
  • Basic knowledge of TypeScript and Next.js 14+ (App Router)
  • Editor like VS Code with TypeScript extension
  • Testing tools: curl or Postman
  • 10 minutes for a working project

Initialize the Next.js project

terminal
npx create-next-app@latest@canary rest-api-tutorial --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd rest-api-tutorial
npm install zod

This command creates a Next.js 15+ project (canary for 2026 features) with TypeScript, Tailwind (optional here), ESLint, and App Router. The @/* alias simplifies imports. Zod adds bulletproof runtime validation, preventing JSON body parsing errors.

Understanding the App Router structure

The App Router places API routes under src/app/api/. Each route.ts file exports HTTP handlers (GET, POST, etc.). Dynamic routes use [param].

We'll create:

  • /api/todos for listing (GET) and creating (POST).
  • /api/todos/[id] for reading (GET), updating (PUT), and deleting (DELETE).

Storage uses a global array in ./lib/store.ts, persisting via Node.js module cache—great for demos. For production, migrate to a real DB like Drizzle.

Define types and Zod schemas

src/lib/schemas.ts
import { z } from 'zod';

export interface Todo {
  id: string;
  title: string;
  completed: boolean;
  createdAt: Date;
}

export const todoSchema = z.object({
  title: z.string().min(3, 'Title too short').max(100),
  completed: z.boolean().optional(),
});

export const todoUpdateSchema = z.object({
  title: z.string().min(3).max(100).optional(),
  completed: z.boolean().optional(),
});

TypeScript types provide full IDE support, while Zod validates JSON payloads at runtime. todoSchema for POST, todoUpdateSchema for PUT (optional fields). Pitfall: always use .optional() on updates to avoid forcing all fields.

Create the in-memory store

src/lib/store.ts
import type { Todo } from '@/lib/schemas';

let todos: Todo[] = [
  { id: '1', title: 'Learn Next.js', completed: false, createdAt: new Date() },
  { id: '2', title: 'Validate with Zod', completed: true, createdAt: new Date() },
];

export function getTodos(): Todo[] {
  return todos;
}

export function getTodoById(id: string): Todo | undefined {
  return todos.find(todo => todo.id === id);
}

export function createTodo(todoData: Omit<Todo, 'id' | 'createdAt'>): Todo {
  const id = Math.random().toString(36).slice(2);
  const newTodo: Todo = { id, ...todoData, createdAt: new Date() };
  todos.push(newTodo);
  return newTodo;
}

export function updateTodo(id: string, updates: Partial<Todo>): Todo | null {
  const todo = getTodoById(id);
  if (!todo) return null;
  Object.assign(todo, updates);
  return todo;
}

export function deleteTodo(id: string): boolean {
  const index = todos.findIndex(todo => todo.id === id);
  if (index === -1) return false;
  todos.splice(index, 1);
  return true;
}

This store simulates a database with basic CRUD. Unique IDs via random hex. Partial for flexible updates. Pitfall: in production, replace with a mutex (like from async-mutex) for multi-instance Vercel support; module cache works for dev.

Implement the main /todos route

src/app/api/todos/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { todoSchema } from '@/lib/schemas';
import { createTodo, getTodos } from '@/lib/store';

export async function GET() {
  const todos = getTodos();
  return NextResponse.json(todos);
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const validated = todoSchema.parse(body);
    const todo = createTodo(validated);
    return NextResponse.json(todo, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json({ error: error.errors[0].message }, { status: 400 });
    }
    return NextResponse.json({ error: 'Internal error' }, { status: 500 });
  }
}

GET lists all todos. POST validates the body with Zod, creates it, and returns 201. Handles ZodError for precise 400s. Pitfall: always await request.json(); without try/catch, Zod errors crash the API—granular errors improve developer experience.

Dynamic route for a specific todo

Next step: Create the src/app/api/todos/[id] folder and add route.ts. It handles GET/PUT/DELETE by ID, extracted via params.id. Use searchParams for future query strings.

Dynamic route /todos/[id]

src/app/api/todos/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { todoUpdateSchema } from '@/lib/schemas';
import { deleteTodo, getTodoById, updateTodo } from '@/lib/store';

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const todo = getTodoById(params.id);
  if (!todo) {
    return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
  }
  return NextResponse.json(todo);
}

export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const body = await request.json();
    const validated = todoUpdateSchema.parse(body);
    const updated = updateTodo(params.id, validated);
    if (!updated) {
      return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
    }
    return NextResponse.json(updated);
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json({ error: error.errors[0].message }, { status: 400 });
    }
    return NextResponse.json({ error: 'Internal error' }, { status: 500 });
  }
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const deleted = deleteTodo(params.id);
  if (!deleted) {
    return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
  }
  return new NextResponse(null, { status: 204 });
}

GET/PUT/DELETE use params.id. Returns 404 if not found, 204 no-content on DELETE. Zod only on PUT (not DELETE). Pitfall: strictly type { params: { id: string } }—without it, TypeScript is loose and runtime crashes occur.

Run and test the API

terminal
npm run dev

# New terminal:
curl http://localhost:3000/api/todos
curl -X POST http://localhost:3000/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "New task"}'

curl http://localhost:3000/api/todos/1
curl -X PUT http://localhost:3000/api/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

curl -X DELETE http://localhost:3000/api/todos/1

Starts the dev server. These curl commands test full CRUD: list, create (201), read (200), update, delete (204). Pitfall: forget Content-Type: application/json and body parsing fails silently.

Best practices

  • Systematic validation: Use Zod everywhere for inputs/outputs; integrate z.infer for auto-types.
  • Precise HTTP status: 201 for create, 204 for delete, granular 400/404/500—Postman loves it.
  • Rate limiting: Add upstash/ratelimit for production anti-abuse.
  • Logging: Integrate pino in middleware for error tracing.
  • Deployment: vercel --prod directly, with env vars for a real DB.

Common errors to avoid

  • Missing params typing: Dynamic routes crash without { params: { id: string } }.
  • No try/catch for Zod: Validation errors pollute logs with 500s.
  • Global storage without mutex: Concurrent calls corrupt data (use async-mutex).
  • Missing CORS in prod: Add next.config.js headers for external frontends.

Next steps

Upgrade to a real database with Drizzle ORM + Turso. Add auth via NextAuth v6. Optimize with React Server Components for hybrid fullstack.

Check out our Learni Dev courses on advanced Next.js and scalable architectures. Contribute to this tutorial on GitHub!