Building a REST API with Next.js Route Handlers

Step 11 of 31Next.js Tutorial Series | Source code for this step

Live Demo →

What You Will Build

↑ Index

By the end of this step, your app will have two API endpoints:

  • GET /api/posts — returns all posts as JSON
  • POST /api/posts — creates a new post and returns it as JSON

These endpoints can be consumed by any client — your own frontend, a mobile app, Postman, or curl.

Goal: Learn how Next.js Route Handlers work, how they differ from page components, and how to handle different HTTP methods.


Table of Contents

  1. What is a Route Handler?
  2. Core Concept: route.ts vs page.tsx
  3. Core Concept: The Request and NextResponse Objects
  4. Create the GET Endpoint
  5. Test the GET Endpoint
  6. Create the POST Endpoint
  7. Test the POST Endpoint
  8. Understanding the Code
  9. Why No "use server" in Route Handlers?
  10. Adding Query Parameters
  11. Error Handling
  12. Summary & Key Takeaways

What is a Route Handler?

↑ Index

So far, every file we have created renders HTML. But sometimes you need an endpoint that returns raw data — JSON, for example — instead of a web page. This is what APIs do.

In Next.js, you build API endpoints using Route Handlers. A Route Handler is a file named route.ts that exports functions matching HTTP methods: GET, POST, PUT, PATCH, DELETE.

app/api/posts/route.ts

When a request hits /api/posts, Next.js looks for a route.ts file in the matching folder and calls the exported function that matches the HTTP method.


Core Concept: route.ts vs page.tsx

↑ Index

Both route.ts and page.tsx live inside the app/ directory and use file-based routing. But they serve different purposes:

page.tsxroute.ts
PurposeRenders a web page (HTML)Returns data (JSON, text, etc.)
Default exportA React componentNamed exports: GET, POST, etc.
ResponseJSX that becomes HTMLNextResponse.json(...) or Response
URL example/posts renders a page/api/posts returns JSON

Important rule: A folder cannot have both page.tsx and route.ts. They are mutually exclusive. That is why we put API routes under app/api/ — it keeps them separate from pages.


Core Concept: The Request and NextResponse Objects

↑ Index

Every Route Handler function receives a standard Web API Request object and returns a Response. Next.js also provides NextResponse — a helper with extra convenience methods.

import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  return NextResponse.json({ message: 'Hello' })
}

Key facts:

  • request is the standard Web Fetch API Request. It has .url, .method, .headers, .json(), etc.
  • NextResponse.json() creates a JSON response with the correct Content-Type: application/json header.
  • You can also return a plain Response if you want full control.

Create the GET Endpoint

↑ Index

Create the folder structure and file:

mkdir -p app/api/posts

Create app/api/posts/route.ts:

import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'

export async function GET() {
  const posts = await prisma.post.findMany({
    include: { author: true },
    orderBy: { createdAt: 'desc' },
  })

  return NextResponse.json(posts)
}

That is the entire file. When a GET request hits /api/posts, Next.js calls this function, queries the database, and returns the result as JSON.


Test the GET Endpoint

↑ Index

Start your dev server if it is not running:

pnpm dev

Option 1 — Browser:

Open http://localhost:3000/api/posts in your browser. You should see a JSON array of all posts with their authors.

Option 2 — curl:

curl http://localhost:3000/api/posts

Option 3 — Browser DevTools:

Open the console on any page and run:

const res = await fetch('/api/posts')
const data = await res.json()
console.log(data)

You should see the same data you see on the /posts page, but as raw JSON instead of rendered HTML.


Create the POST Endpoint

↑ Index

Now add a POST function to the same file (app/api/posts/route.ts). This lets clients create new posts by sending JSON in the request body.

Update app/api/posts/route.ts to include both handlers:

import { NextResponse } from 'next/server'
import prisma from '@/lib/prisma'

export async function GET() {
  const posts = await prisma.post.findMany({
    include: { author: true },
    orderBy: { createdAt: 'desc' },
  })

  return NextResponse.json(posts)
}

export async function POST(request: Request) {
  const body = await request.json()

  const post = await prisma.post.create({
    data: {
      title: body.title,
      content: body.content,
      authorId: body.authorId,
    },
    include: { author: true },
  })

  return NextResponse.json(post, { status: 201 })
}

The second argument { status: 201 } sets the HTTP status code to 201 Created — the standard response for successful resource creation.

Note: In this tutorial series, we won't actually call this POST endpoint from the frontend. In Step 16, we use Server Actions instead of an API route to create posts. The POST handler here is kept as a reference — it demonstrates how Route Handlers work with request bodies, and it's ready to use if you ever need a public API for external clients, mobile apps, or third-party integrations.


Test the POST Endpoint

↑ Index

You cannot test POST in the browser address bar (browsers send GET by default). Use one of these methods:

Finding a valid authorId: Our User model uses cuid strings for IDs (not integers). You need a real user ID from your database. The easiest way to find one is to open http://localhost:3000/api/posts in your browser — each post's author.id field shows a valid user ID (e.g., "cm5abc123def456"). Copy one of those values and use it in the examples below.

Option 1 — curl:

curl -X POST http://localhost:3000/api/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "My API Post", "content": "Created via the API!", "authorId": "PASTE_A_REAL_USER_ID_HERE"}'

Option 2 — Browser DevTools console:

const res = await fetch('/api/posts', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: 'My API Post',
    content: 'Created via the API!',
    authorId: 'PASTE_A_REAL_USER_ID_HERE',
  }),
})
const data = await res.json()
console.log(data)

Getting authorId type error? If you see Expected String or Null, provided Int, make sure authorId is a string (in quotes), not a number. Our User.id is defined as String @default(cuid()) in the schema, so authorId must be a string like "cm5abc123def456", not an integer like 1.

You should see the newly created post returned as JSON with a 201 status. Refresh /posts in the browser — the new post should appear at the top of the list.


Understanding the Code

↑ Index

Exported function names = HTTP methods

export async function GET() { ... }
export async function POST(request: Request) { ... }

Next.js routes requests based on the function name. A GET request calls GET(), a POST request calls POST(), and so on. The supported names are: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.

If a client sends a method you have not exported (e.g., DELETE), Next.js automatically returns 405 Method Not Allowed.

Reading the request body

const body = await request.json()

request.json() parses the request body as JSON and returns a JavaScript object. It is an async method — you must await it.

The body variable has type any by default. In a production app, you would validate it (we will add validation in a later step). Never trust that the client sends the correct shape.

prisma.post.create()

const post = await prisma.post.create({
  data: {
    title: body.title,
    content: body.content,
    authorId: body.authorId,
  },
  include: { author: true },
})

create() inserts a new row into the Post table. The data object maps to the columns. include: { author: true } tells Prisma to also fetch the related author and attach it to the returned post — so the API response includes the author object, not just authorId.

The status option

return NextResponse.json(post, { status: 201 })

By default, NextResponse.json() returns status 200 OK. For a POST that creates a resource, the conventional response is 201 Created. The second argument lets you set the status code and other response options like headers.

Why GET() does not need the request parameter

export async function GET() {

The GET handler does not use the request object because it does not need to read any input from the client — it simply returns all posts. TypeScript allows you to omit unused parameters. If you later need query parameters or headers, you can add request: Request to the function signature.


Why No "use server" in Route Handlers?

↑ Index

You might wonder: these are async functions that run on the server and access the database — why don't they have "use server" at the top?

"use server" is not needed here because this is an API Route (route.ts), not a Server Action. They are two completely different mechanisms:

API Route (route.ts)Server Action ("use server")
Whereapp/api/.../route.tsAny .ts file with "use server", or inline in a Server Component
How it runsAs an HTTP endpoint (GET, POST, etc.)As an RPC function called directly from client code
How client calls itfetch("/api/posts")await createPost(formData) — like calling a normal function
Already server-only?Yesroute.ts files only run on the server by definitionNeeds "use server" to tell Next.js "this function should stay on the server and be callable from the client"

The key insight: "use server" is not a generic "run on server" marker. It specifically means "this is a Server Action — expose it as an RPC endpoint that client components can call like a function."

API route handlers (GET, POST in route.ts) are already server-only — the browser never receives this code. Adding "use server" would actually be incorrect here because these are not Server Actions; they are HTTP request handlers.

When you need "use server": only for Server Actions — async functions that a client component calls directly (e.g., in a form action or via onClick). We will create an actions.ts file with Server Actions in Step 16: Creating Posts with Server Actions.


Adding Query Parameters

↑ Index

What if you want to let the client control how many posts are returned? You can read query parameters from the URL.

For example, GET /api/posts?take=5 should return only 5 posts.

Update the GET function:

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const take = searchParams.get('take')

  const posts = await prisma.post.findMany({
    include: { author: true },
    orderBy: { createdAt: 'desc' },
    ...(take && { take: Number(take) }),
  })

  return NextResponse.json(posts)
}

How it works

  1. new URL(request.url) parses the full URL into a URL object.
  2. .searchParams gives you a URLSearchParams object — a built-in Web API for reading query parameters.
  3. .get('take') returns the value as a string, or null if the parameter is not present.
  4. ...(take && { take: Number(take) }) — this is a conditional spread. If take exists, it adds take: 5 to the Prisma query. If not, nothing is added and all posts are returned.

Test it:

# Returns only 3 posts
curl http://localhost:3000/api/posts?take=3

# Returns all posts (no take parameter)
curl http://localhost:3000/api/posts

The conditional spread pattern

...(take && { take: Number(take) })

This pattern is worth understanding because it appears often in Next.js and Prisma code. It works in three steps:

  1. take — is the value truthy? (non-null, non-empty string)
  2. If yes: { take: Number(take) } — create an object with the property.
  3. ... — spread that object into the parent. If step 1 was falsy, nothing is spread.

The result: the property is conditionally included in the object without an if statement.


Error Handling

↑ Index

In a production app, you should handle errors gracefully. Here is a pattern for the POST handler:

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

    if (!body.title || !body.content) {
      return NextResponse.json(
        { error: 'Title and content are required' },
        { status: 400 }
      )
    }

    const post = await prisma.post.create({
      data: {
        title: body.title,
        content: body.content,
        authorId: body.authorId,
      },
      include: { author: true },
    })

    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to create post' },
      { status: 500 }
    )
  }
}

Common HTTP status codes for APIs

StatusMeaningWhen to use
200OKSuccessful GET, PUT, PATCH, DELETE
201CreatedSuccessful POST that creates a resource
400Bad RequestClient sent invalid data (missing fields, wrong types)
404Not FoundThe requested resource does not exist
405Method Not AllowedThe HTTP method is not supported (Next.js handles this automatically)
500Internal Server ErrorSomething went wrong on the server

Summary & Key Takeaways

↑ Index

ConceptWhat it means
Route HandlerA route.ts file that exports HTTP method functions (GET, POST, etc.)
route.ts vs page.tsxroute.ts returns data (JSON); page.tsx renders HTML. Cannot coexist in the same folder
NextResponse.json()Creates a JSON response with the correct headers
request.json()Parses the request body as JSON (must await)
prisma.post.create()Inserts a new record into the database
Query parametersRead with new URL(request.url).searchParams
Status codesUse { status: 201 } for created, { status: 400 } for bad requests, etc.

Looking ahead — do we actually need these API routes?

In Step 12, we will use the GET /api/posts endpoint to fetch posts from a Client Component using useEffect and fetch(). This is the traditional approach: the browser makes a network request to your API, and your API queries the database.

But in Step 20, we refactor that entire pattern. Instead of the round trip — browser → API route → database → API route → browser — we use a Server Component that calls Prisma directly. The component runs on the server, so it can access the database without an API in between. No fetch(), no API route, no extra network hop.

Even in Step 21, where we use the React 19 use() hook to stream data to a Client Component, the Prisma query still starts in a Server Component — not through an API route. The promise is created on the server and passed to the client. No fetch() needed.

So when are API routes useful? They are essential when you need to expose your data to external consumers — a mobile app, a third-party integration, a webhook, or any client outside your Next.js application. If your data is only consumed by your own Next.js pages, Server Components with direct Prisma access are simpler, faster, and require less code. But if you ever need a public API, these Route Handlers are exactly how you build one.

What is Next

In Step 12, we will build client-side pagination. The posts list will show a limited number of posts per page with "Previous" and "Next" buttons — using the API endpoint we just created to fetch data from a Client Component.