Building a REST API with Next.js Route Handlers
Step 11 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
By the end of this step, your app will have two API endpoints:
GET /api/posts— returns all posts as JSONPOST /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
- What is a Route Handler?
- Core Concept:
route.tsvspage.tsx - Core Concept: The
RequestandNextResponseObjects - Create the GET Endpoint
- Test the GET Endpoint
- Create the POST Endpoint
- Test the POST Endpoint
- Understanding the Code
- Why No
"use server"in Route Handlers? - Adding Query Parameters
- Error Handling
- Summary & Key Takeaways
What is a Route Handler?
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
Both route.ts and page.tsx live inside the app/ directory and use file-based routing. But they serve different purposes:
page.tsx | route.ts | |
|---|---|---|
| Purpose | Renders a web page (HTML) | Returns data (JSON, text, etc.) |
| Default export | A React component | Named exports: GET, POST, etc. |
| Response | JSX that becomes HTML | NextResponse.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
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:
requestis the standard Web Fetch APIRequest. It has.url,.method,.headers,.json(), etc.NextResponse.json()creates a JSON response with the correctContent-Type: application/jsonheader.- You can also return a plain
Responseif you want full control.
Create the GET Endpoint
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
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
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
POSTendpoint from the frontend. In Step 16, we use Server Actions instead of an API route to create posts. ThePOSThandler 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
You cannot test POST in the browser address bar (browsers send GET by default). Use one of these methods:
Finding a valid
authorId: OurUsermodel uses cuid strings for IDs (not integers). You need a real user ID from your database. The easiest way to find one is to openhttp://localhost:3000/api/postsin your browser — each post'sauthor.idfield 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
authorIdtype error? If you seeExpected String or Null, provided Int, make sureauthorIdis a string (in quotes), not a number. OurUser.idis defined asString @default(cuid())in the schema, soauthorIdmust be a string like"cm5abc123def456", not an integer like1.
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
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?
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") | |
|---|---|---|
| Where | app/api/.../route.ts | Any .ts file with "use server", or inline in a Server Component |
| How it runs | As an HTTP endpoint (GET, POST, etc.) | As an RPC function called directly from client code |
| How client calls it | fetch("/api/posts") | await createPost(formData) — like calling a normal function |
| Already server-only? | Yes — route.ts files only run on the server by definition | Needs "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 formactionor viaonClick). We will create anactions.tsfile with Server Actions in Step 16: Creating Posts with Server Actions.
Adding Query Parameters
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
new URL(request.url)parses the full URL into aURLobject..searchParamsgives you aURLSearchParamsobject — a built-in Web API for reading query parameters..get('take')returns the value as a string, ornullif the parameter is not present....(take && { take: Number(take) })— this is a conditional spread. Iftakeexists, it addstake: 5to 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:
take— is the value truthy? (non-null, non-empty string)- If yes:
{ take: Number(take) }— create an object with the property. ...— 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
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
| Status | Meaning | When to use |
|---|---|---|
200 | OK | Successful GET, PUT, PATCH, DELETE |
201 | Created | Successful POST that creates a resource |
400 | Bad Request | Client sent invalid data (missing fields, wrong types) |
404 | Not Found | The requested resource does not exist |
405 | Method Not Allowed | The HTTP method is not supported (Next.js handles this automatically) |
500 | Internal Server Error | Something went wrong on the server |
Summary & Key Takeaways
| Concept | What it means |
|---|---|
| Route Handler | A route.ts file that exports HTTP method functions (GET, POST, etc.) |
route.ts vs page.tsx | route.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 parameters | Read with new URL(request.url).searchParams |
| Status codes | Use { 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/postsendpoint to fetch posts from a Client Component usinguseEffectandfetch(). 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. Nofetch()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.