Lesson 30 of 5028 min read

Build a Full REST API in Next.js with Route Handlers

Build a complete, production-quality CRUD REST API in Next.js using Route Handlers, Prisma, request validation, and consistent error handling.

Author: CodersNexus

Build a Full REST API in Next.js with Route Handlers

Module 4 introduced Route Handlers with a simplified, illustrative example. Now, with Prisma and real database connections from this module's earlier lessons in hand, it's time to build a genuinely complete, production-quality REST API: full CRUD (Create, Read, Update, Delete) operations against a real, migrated database table, with proper request validation using the Zod patterns from Lesson 3.4, and consistent, well-structured error handling across every endpoint.

This lesson brings together nearly everything covered so far in this module — database connections, Prisma queries, Route Handlers, and validation — into one cohesive, realistic backend feature: a complete API for managing blog posts, the exact same domain used in this module's culminating full-stack project.

Learning Objectives

  • Implement full CRUD operations (Create, Read, Update, Delete) using Route Handlers and Prisma.
  • Validate incoming request data using a shared Zod schema before touching the database.
  • Return consistent, well-structured error responses with appropriate HTTP status codes.
  • Handle not-found and validation-failure cases distinctly and correctly.
  • Structure a multi-endpoint REST API cleanly across collection-level and item-level routes.

Core Definitions

  • CRUD: An acronym for Create, Read, Update, Delete — the four fundamental operations most REST APIs implement for a given resource.
  • Collection-level endpoint: An API route representing a resource type as a whole, such as /api/posts, typically handling listing (GET) and creation (POST).
  • Item-level endpoint: An API route representing one specific resource instance, such as /api/posts/[id], typically handling reading, updating, and deleting that one item.
  • Consistent error shape: A predictable, standardized structure (like { error: string }) used across all of an API's error responses, making the API easier and more reliable for any client to consume.
  • HTTP status code: A three-digit code indicating the outcome of an HTTP request, such as 200 (success), 201 (created), 400 (bad request/validation error), 404 (not found), or 500 (server error).

How Build Rest api Next.js Actually Works

A well-structured REST API for a resource like blog posts is organized around exactly two levels of routes, following the pattern introduced in Module 4: a collection-level route at app/api/posts/route.ts handling GET (listing all posts) and POST (creating a new post), and an item-level route at app/api/posts/[id]/route.ts handling GET (reading one specific post), PUT or PATCH (updating it), and DELETE (removing it).

Every handler that receives data from the client — POST and PUT in this case — should validate that incoming data using a shared Zod schema, exactly as covered in Lesson 3.4, before it ever touches the database via Prisma. If validation fails, the handler should return a 400 Bad Request status with a clear, specific error message describing what was wrong, rather than allowing invalid data to reach Prisma and potentially cause a less helpful, lower-level database error.

For item-level operations (GET, PUT, DELETE on /api/posts/[id]), a critical, easy-to-overlook detail is correctly handling the case where the requested id simply doesn't exist. Prisma's findUnique will return null (not throw an error) if no matching record is found, so your handler must explicitly check for this and return a 404 Not Found status with an appropriate message — silently returning null as if it were a 200 OK success, or letting a subsequent operation on that null value throw an unhandled, generic 500 error, are both poor practices a well-built API avoids.

Beyond validation and not-found handling, a genuinely production-quality API wraps its core logic in try/catch blocks to handle unexpected errors gracefully — a database connection temporarily failing, an unexpected data shape — returning a generic 500 Internal Server Error with a safe, non-revealing message (never exposing raw internal error details or stack traces to the client, which could leak sensitive implementation information) while logging the actual detailed error server-side for your own debugging purposes.

Maintaining a consistent error response shape across every single endpoint in your API — for example, always returning `{ error: 'specific message' }` on failure, regardless of which endpoint or failure type — makes your API significantly easier and more predictable for any client (your own frontend, a mobile app, a third-party integration) to consume, since error-handling logic on the client side can be written once and applied uniformly rather than needing special-case handling for each individual endpoint's particular error format.

Visualizing Build Rest api Next.js

{"heading":"Visualizing Build Rest api Next.js","description":"Visualize the complete REST resource structure and its error-handling flow:\n\napp/api/posts/route.ts\n ├── GET → list posts (200) | unexpected error (500)\n └── POST → validate body (400 if invalid) → create (201) | unexpected error (500)\n\napp/api/posts/[id]/route.ts\n ├── GET → find post → not found (404) | found (200) | unexpected error (500)\n ├── PUT → validate body (400) → find post → not found (404) → update (200)\n └── DELETE → find post → not found (404) → delete (200) | unexpected error (500)\n\nConsistent error shape across ALL endpoints: { error: 'specific, clear message' }"}

Next.js Practical Example

// schemas/postSchema.ts — shared validation schema
import { z } from 'zod';

export const postSchema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters'),
  content: z.string().min(1, 'Content is required'),
});

// app/api/posts/route.ts — collection-level: list + create
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { postSchema } from '@/schemas/postSchema';

export async function GET() {
  try {
    const posts = await prisma.post.findMany();
    return NextResponse.json(posts);
  } catch {
    return NextResponse.json({ error: 'Something went wrong' }, { status: 500 });
  }
}

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const result = postSchema.safeParse(body);

    if (!result.success) {
      return NextResponse.json(
        { error: result.error.issues[0].message },
        { status: 400 }
      );
    }

    const post = await prisma.post.create({ data: result.data });
    return NextResponse.json(post, { status: 201 });
  } catch {
    return NextResponse.json({ error: 'Something went wrong' }, { status: 500 });
  }
}

// app/api/posts/[id]/route.ts — item-level: read, update, delete one post
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { postSchema } from '@/schemas/postSchema';

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const post = await prisma.post.findUnique({ where: { id } });

  if (!post) {
    return NextResponse.json({ error: 'Post not found' }, { status: 404 });
  }
  return NextResponse.json(post);
}

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  const existing = await prisma.post.findUnique({ where: { id } });

  if (!existing) {
    return NextResponse.json({ error: 'Post not found' }, { status: 404 });
  }

  await prisma.post.delete({ where: { id } });
  return NextResponse.json({ success: true });
}

postSchema is defined once and reused across both the POST handler here and, ideally, any corresponding client-side form, following the exact shared-schema pattern from Lesson 3.4. The GET and POST handlers on the collection route are each wrapped in try/catch, returning a safe, generic 500 error on any unexpected failure while the specific validation error (400, with a precise message) is returned distinctly for bad input. The item-level GET and DELETE handlers both explicitly check whether prisma.post.findUnique returned null before proceeding, correctly returning a 404 Not Found rather than either silently succeeding with no data or allowing a subsequent operation on a nonexistent record to throw an unhandled error — this explicit not-found check is exactly the detail that separates a genuinely production-quality API from a merely functional prototype.

Real-World Examples: How Companies Use Build Rest api Next.js

  • SaaS platforms building a public or partner-facing API implement this exact combination of shared validation schemas and consistent error shapes specifically so external developers integrating against their API have a predictable, well-documented experience.
  • E-commerce backends implementing a product management API rely on explicit 404 handling for item-level endpoints, since attempting operations on a deleted or nonexistent product ID is a common, expected real-world scenario that must be handled gracefully rather than crashing.
  • Content management systems expose a REST API following this exact CRUD pattern for posts, pages, and media, often with the same Zod schema shared between the CMS's own admin UI and the underlying API.
  • Mobile app backends built on Next.js Route Handlers depend heavily on consistent error shapes and status codes, since a native mobile app's own error-handling logic is written once against the API's documented, predictable response format.
  • Internal admin tools and dashboards consuming a company's own Next.js-based API rely on the same consistent 400/404/500 error handling patterns to reliably surface meaningful error messages to internal users rather than generic, unhelpful failures.

Common Mistakes to Avoid

  • Forgetting to explicitly check for a null result from findUnique, either silently returning null as a success or causing an unhandled error on a subsequent operation.
  • Skipping request validation entirely and letting potentially malformed data reach Prisma directly, resulting in confusing, low-level database errors instead of clear, actionable 400 responses.
  • Returning inconsistent error response shapes across different endpoints, making the API harder for any client to reliably consume.
  • Exposing raw error objects or stack traces directly in a 500 response, potentially leaking sensitive internal implementation details.
  • Using the wrong HTTP status code for a given scenario — such as returning 200 for a failed operation, or 500 for a client's own invalid input that should correctly be a 400.

Interview Notes

  • A full CRUD REST resource is typically split into a collection-level route (list/create) and an item-level route with a dynamic segment (read/update/delete one item).
  • Incoming request data should be validated via a shared Zod schema before any database operation, returning 400 on failure.
  • Prisma's findUnique returns null (not an error) for a missing record; handlers must explicitly check for this and return 404.
  • Unexpected errors should be caught and return a generic, safe 500 response, with the actual error logged server-side.
  • A consistent error response shape ({ error: 'message' }) across all endpoints makes an API predictable and easier to consume.

Key Takeaways

  • Building a genuinely production-quality REST API is about far more than making the 'happy path' work — explicit not-found handling, validation, and safe error responses are equally essential.
  • Sharing a single Zod schema between validation logic and (ideally) a corresponding client-side form eliminates duplicated, potentially inconsistent rules.
  • Correctly distinguishing 400 (bad client input), 404 (resource doesn't exist), and 500 (unexpected server failure) is fundamental to building an API that's genuinely usable and debuggable by any client.
  • This lesson pulls together nearly every backend concept from this module — connections, Prisma, Route Handlers, and validation — into one complete, realistic feature.

Summary

Building a complete, production-quality REST API in Next.js means implementing full CRUD operations across a collection-level route (handling listing and creation) and an item-level route with a dynamic segment (handling reading, updating, and deleting one specific resource), using Prisma for all database operations. Every handler receiving client data validates it first using a shared Zod schema, returning a clear 400 Bad Request response for invalid input before the database is ever touched. Item-level operations must explicitly check for Prisma's null result on a missing record, returning an appropriate 404 Not Found rather than silently succeeding or crashing. Unexpected failures are caught and return a safe, generic 500 response, with real error details logged only server-side, never exposed to the client. Maintaining this consistent error response shape — { error: 'message' } — across every endpoint, paired with correct, meaningful HTTP status codes throughout, is what distinguishes a genuinely reliable, professional REST API from a merely functional prototype.