API Routes and Server Actions

Building an API with Route Handlers, mutating data with Server Actions, and when to use each.

Route Handlers

A Route Handler turns a folder in app/ into an API endpoint instead of a page, by adding a route.ts file that exports functions named after HTTP methods:

Typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';

const users = [
  { id: 1, name: 'Ada Lovelace' },
  { id: 2, name: 'Grace Hopper' },
];

export async function GET() {
  return NextResponse.json(users);
}

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

  if (!body.name) {
    return NextResponse.json({ error: 'name is required' }, { status: 400 });
  }

  const newUser = { id: users.length + 1, name: body.name };
  users.push(newUser);
  return NextResponse.json(newUser, { status: 201 });
}
Typescript
// app/api/users/[id]/route.ts
import { NextResponse } from 'next/server';

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params;
  const user = users.find((u) => u.id === Number(id));

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

A route.ts file and a page.tsx file can't both live in the same folder — a folder is either a page or an API endpoint at a given path.

Server Actions

A Server Action is an async function, marked with 'use server', that runs on the server but can be called directly from a component — most often to handle a form submission — with no separate API endpoint and no client-side fetch call to write:

Typescript
// app/posts/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title');

  if (typeof title !== 'string' || title.trim() === '') {
    throw new Error('Title is required');
  }

  await fetch('https://api.example.com/posts', {
    method: 'POST',
    body: JSON.stringify({ title }),
    headers: { 'Content-Type': 'application/json' },
  });

  revalidatePath('/posts'); // tell Next this route's cached data is now stale
}
Typescript
// app/posts/new/page.tsx
import { createPost } from '../actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Post title" />
      <button type="submit">Create post</button>
    </form>
  );
}

Passing the Server Action straight to a <form>'s action prop works even with JavaScript disabled in the browser — the form submits as a normal HTML form post that Next intercepts and routes to the server function. revalidatePath (or revalidateTag) is how a Server Action tells Next that cached data elsewhere in the app (like the statically rendered /posts list from the previous page) is now out of date and should be regenerated.

Server Actions vs Route Handlers — which to reach for

Server Action Route Handler
Typical use A mutation triggered from your own app's UI (a form, a button) A general-purpose API — consumed by your app, a mobile client, or a third party
Called from Directly, as a function reference (<form action={fn}>, onClick) An HTTP request (fetch, curl, another service)
Needs a URL/endpoint No Yes
Good fit for Form submissions, simple CRUD tied to one app A public or versioned API contract other clients depend on

Common mistakes

  • Forgetting the 'use server' directive at the top of a Server Action file (or function) — without it, Next has no way to know the function should run only on the server.
  • Building a full Route Handler plus a client-side fetch call for a same-app form submission that a single Server Action would have handled more simply.
  • Mutating data in a Server Action but forgetting to call revalidatePath/revalidateTag afterward — the mutation succeeds, but statically cached pages keep showing the old data until the next scheduled regeneration.