Skip to content

Building an API

An API lets you create endpoints (URLs) that run code on the server. In Next.js, you have two ways to run server-side code:

  1. API Routes - traditional endpoints that accept HTTP requests (like from a mobile app or another server)
  2. Server Actions - special functions you can call directly from your React components

We will cover both in this guide.

When you send a request to a server, you use an HTTP method to tell the server what you want to do. Think of these as “actions”:

  • GET - Fetch/read some data (like loading a list of users)
  • POST - Send data to create something new (like registering a new user)
  • PUT - Replace/update an existing item completely (like updating a full user profile)
  • PATCH - Update only part of an existing item (like changing just the email)
  • DELETE - Remove something (like deleting a user account)

In Next.js (App Router), you create API routes by making a route.ts file inside the app/api/ folder. Each folder represents part of the URL.

Here is how the folder structure looks:

app
└── api
├── products
│ └── route.ts # handles /api/products
|
└── users
├── route.ts # handles /api/users
|
└── [id]
└── route.ts # handles /api/users/123 (any user id)

The [id] folder is a dynamic route - the id part can be any value (like a user ID).

Next.js gives you NextRequest (to read the incoming request) and NextResponse (to send back a response). You export functions named after the HTTP method you want to handle.

There are 5 common things you do with data: List, Create, Read one, Update, and Delete.

Get a list of all users:

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
// fetch users from your database here
const users = [
{ id: 1, name: "John Doe" },
{ id: 2, name: "Jane Doe" },
];
return NextResponse.json(users, { status: 200 }); // status 200 means "OK"
}

Create a new user by sending data in the request body:

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const { name } = await request.json(); // read the data sent by the client
// check that required fields are present
if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 }); // 400 = bad request
}
// save the new user to your database here
const newUser = { id: Date.now(), name };
return NextResponse.json(newUser, { status: 201 }); // 201 = "Created"
}

Update an existing user by their ID. The ID comes from the URL (e.g. /api/users/5):

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
interface Props {
params: Promise<{ id: string }>;
}
export async function PUT(request: NextRequest, { params }: Props) {
const { id } = await params;
const { name } = await request.json();
if (!id) {
return NextResponse.json({ error: "User ID is required" }, { status: 400 });
}
if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
// update the user in your database here
const updatedUser = { id, name };
return NextResponse.json(updatedUser, { status: 200 });
}

Delete a user by their ID:

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
interface Props {
params: Promise<{ id: string }>;
}
export async function DELETE(request: NextRequest, { params }: Props) {
const { id } = await params;
if (!id) {
return NextResponse.json({ error: "User ID is required" }, { status: 400 });
}
// delete the user from your database here
return NextResponse.json({ message: `User with ID ${id} has been deleted` }, { status: 200 });
}

Zod is a library that helps you check that the data you receive is in the right format. Instead of writing lots of if checks yourself, you define a “schema” (a set of rules) and Zod checks the data against it automatically.

Install Zod:

Terminal window
npm install zod

Folder structure with a schema file:

app
└── api
└── users
├── route.ts
├── schema.ts # your Zod validation rules live here
|
└── [id]
└── route.ts

Define your schema:

// app/api/users/schema.ts
import { z } from "zod";
export const userSchema = z.object({
id: z.number().optional(), // id is a number, but not required
name: z.string().min(1, "Name is required"), // name must be a non-empty string
});

Use the schema in your route to validate incoming data:

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { userSchema } from "./schema";
export async function POST(request: NextRequest) {
const body = await request.json();
// safeParse checks the data without throwing an error
const parsedData = userSchema.safeParse(body);
if (!parsedData.success) {
// if validation fails, return the errors
return NextResponse.json({ error: parsedData.error.errors }, { status: 400 });
}
const newUser = parsedData.data; // the validated and typed data
// save the new user to your database here
return NextResponse.json(newUser, { status: 201 });
}

There are two ways to validate with Zod:

  • parse - throws an error if the data is invalid (use inside try/catch)
  • safeParse - returns a result object with success: true/false (easier to handle)

Server Actions are functions that run on the server but can be called directly from your React components - no need to create an API route or use fetch. They are great for form submissions and simple data mutations.

To create a Server Action, add "use server" at the top of the function or the file.

// app/actions/userActions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createUser(formData: FormData) {
const name = formData.get("name") as string;
if (!name) {
return { error: "Name is required" };
}
// save the new user to your database here
// const newUser = await db.user.create({ data: { name } });
revalidatePath("/users"); // refresh the /users page so it shows the new data
return { success: true };
}
// app/users/new/page.tsx
import { createUser } from "@/app/actions/userActions";
export default function NewUserPage() {
return (
<form action={createUser}>
<input type="text" name="name" placeholder="Enter name" required />
<button type="submit">Create User</button>
</form>
);
}

That’s it - no fetch, no API route needed. When the user submits the form, createUser runs on the server automatically.

Using a Server Action with Validation (Zod)

Section titled “Using a Server Action with Validation (Zod)”

You can combine Server Actions with Zod for clean validation:

// app/actions/userActions.ts
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const userSchema = z.object({ name: z.string().min(1, "Name is required") });
export async function createUser(formData: FormData) {
const rawData = { name: formData.get("name") };
const parsedData = userSchema.safeParse(rawData);
if (!parsedData.success) {
return { error: parsedData.error.errors[0].message };
}
// save user to database here
revalidatePath("/users");
return { success: true };
}

Using a Server Action with useActionState (for form feedback)

Section titled “Using a Server Action with useActionState (for form feedback)”

If you want to show a success or error message after the form submits, use the useActionState hook:

// app/users/new/page.tsx
"use client"; // this is needed because we are using a hook
import { useActionState } from "react";
import { createUser } from "@/app/actions/userActions";
export default function NewUserPage() {
const [state, formAction, isPending] = useActionState(createUser, null);
return (
<form action={formAction}>
<input type="text" name="name" placeholder="Enter name" required />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create User"}
</button>
{state?.error && <p style={{ color: "red" }}>{state.error}</p>}
{state?.success && <p style={{ color: "green" }}>User created!</p>}
</form>
);
}

Server Actions vs API Routes - When to Use Which?

Section titled “Server Actions vs API Routes - When to Use Which?”

This is a common question. Here is a simple way to think about it:

SituationUse
Submitting a form in your Next.js appServer Action
Mutating data from a React componentServer Action
You need a public URL for the endpointAPI Route
A mobile app or external service needs to call itAPI Route
Another server needs to call itAPI Route
You want to reuse the same endpoint in multiple placesAPI Route
You need fine-grained control over headers/cookies/status codesAPI Route
  • Staying inside your Next.js app? - Use a Server Action. Less code, no fetch, works great with forms.
  • Need an endpoint that the outside world can call? - Use an API Route.

With an API Route (more setup):

// 1. Create the route
// app/api/users/route.ts
export async function POST(request: NextRequest) {
const { name } = await request.json();
// save to db...
return NextResponse.json({ success: true });
}
// 2. Call it from your component
const response = await fetch("/api/users", { method: "POST", body: JSON.stringify({ name }) });

With a Server Action (simpler):

// 1. Create the action
// app/actions/userActions.ts
"use server";
export async function createUser(formData: FormData) {
const name = formData.get("name");
// save to db...
}
// 2. Use it directly in your form - no fetch needed!
<form action={createUser}>...</form>
CodeMeaningWhen to use
200OKSuccessful GET, PUT, DELETE
201CreatedSuccessful POST (something was created)
400Bad RequestThe data sent was invalid or missing
401UnauthorizedUser is not logged in
403ForbiddenUser is logged in but not allowed
404Not FoundThe resource does not exist
500Server ErrorSomething broke on the server