Let's be honest for a second. The line between "frontend" and "backend" has gotten wonderfully, chaotically blurry, hasn't it? I remember a time when you were either a React dev wrestling with state, or a Node.js dev wrestling with an Express server. The two worlds met over a REST API, shook hands politely, and then went their separate ways.
Then Next.js came along and started inviting the backend to the frontend party. First, it was server-side rendering. Then, with the pages/api directory, it was basically like, "Hey, what if you just built your whole backend in this one folder?" It was pretty revolutionary.
But now... oh boy, now we have the App Router. And with it, we have two incredibly powerful ways to handle server-side logic: the classic Next.js API Routes (now officially called Route Handlers) and the new, flashy kid on the block, Server Actions.
And if you’re anything like me when I first saw them, you probably stared at your screen and thought, "Okay... so... which one do I use? And why on earth do I need two?" It's a totally valid question, and one that caused me a fair bit of head-scratching. So today, we're going to unravel this whole thing. We'll explore both, build a few things, and by the end, you'll have a rock-solid mental model for when to reach for which tool.
The Old Faithful: Next.js API Routes (Route Handlers)
Let's start with what feels familiar. If you've ever built a REST API with Express, Fastify, or even the old Next.js Pages Router, then Route Handlers will feel like coming home. They are your go-to for creating traditional, web-standard API endpoints.
At their core, they're just dedicated URLs that don't return HTML, but data (usually JSON). They live inside your app directory, typically in an app/api folder, and follow the same file-based routing you already know and love.
So, a file named app/api/hello/route.ts will magically create an endpoint at /api/hello.
Inside that file, you just export functions named after HTTP methods: GET, POST, PUT, DELETE, and so on. It's clean, it's predictable, and it plays by the established rules of the web.
Let's See It in Action
Alright, enough talk. Imagine we want a simple endpoint that just says hello. Check out how ridiculously easy this is:
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
// You can do anything here: fetch from a database, call another API, etc.
return NextResponse.json({ message: 'Hello from the backend!' });
}
Yep, that's it. I'm not kidding. Visit /api/hello in your browser or with a tool like Postman, and you'll get back a neat little JSON object. The NextResponse object is Next.js's handy extension of the standard Response API, giving us some nice helpers like .json().
Building a Full CRUD API
Okay, "hello world" is cute and all, but let's build something a bit more real. A classic to-do list API seems about right. We'll create endpoints to create, read, update, and delete todos. For now, we'll just keep the data in memory to keep things simple—don't worry, you could easily swap this out for a real database later.
First up, the endpoint for getting all todos and creating a new one.
import { NextRequest, NextResponse } from 'next/server';
// In a real app, this comes from a database!
let todos = [
{ id: 1, text: 'Learn Route Handlers', completed: true },
{ id: 2, text: 'Explore Server Actions', completed: false },
];
// GET /api/todos
export async function GET(request: NextRequest) {
return NextResponse.json(todos);
}
// POST /api/todos
export async function POST(request: Request) {
const body = await request.json();
if (!body.text) {
return NextResponse.json(
{ error: 'Todo text is required' },
{ status: 400 }
);
}
const newTodo = {
id: todos.length + 1,
text: body.text,
completed: false,
};
todos.push(newTodo);
return NextResponse.json(newTodo, { status: 201 }); // 201 Created
}
Did you catch that? We're handling both GET and POST in the exact same file. This is the beauty of Route Handlers. The file path defines the resource (/api/todos), and the exported functions define the actions you can perform on that resource. It's a beautiful thing.
Now, what about dealing with a specific todo? You know, like getting, updating, or deleting one by its ID? For that, we use dynamic route segments, just like we do with pages.
Let's go ahead and create app/api/todos/[id]/route.ts:
import { NextResponse } from 'next/server';
// This `todos` array is the same one from the other file.
// In reality, you'd access a shared database instance.
let todos = [
{ id: 1, text: 'Learn Route Handlers', completed: true },
{ id: 2, text: 'Explore Server Actions', completed: false },
];
// GET /api/todos/1
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const todo = todos.find(t => t.id === parseInt(params.id));
if (!todo) {
return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
}
return NextResponse.json(todo);
}
// PUT /api/todos/1
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json();
const index = todos.findIndex(t => t.id === parseInt(params.id));
if (index === -1) {
return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
}
// Update the todo with new data from the body
todos[index] = { ...todos[index], ...body };
return NextResponse.json(todos[index]);
}
// DELETE /api/todos/1
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
const index = todos.findIndex(t => t.id === parseInt(params.id));
if (index === -1) {
return NextResponse.json({ error: 'Todo not found' }, { status: 404 });
}
todos.splice(index, 1);
// A 204 No Content response is common for successful deletions
return new NextResponse(null, { status: 204 });
}
And just like that, you have a fully functional, REST-compliant CRUD API. Your frontend—whether it's a React component in this same Next.js app, a mobile app, or even a third-party service—can now talk to your application's data using standard HTTP requests.
This is powerful. It's stable. It's the lingua franca of the web.
The Paradigm Shift: Enter Server Actions
Okay, so API Routes are great. They're solid, dependable, and work exactly how you'd expect. For a good while, this felt like the peak of full-stack development in Next.js.
But then... the folks at Vercel looked at the most common use case for these API routes—form submissions and data mutations from within the app itself—and asked a wild question: "What if we could... just skip the API route part entirely?"
That simple question is the core idea behind Server Actions.
Let me say that again, because it's a bit of a mind-bender at first: they are functions you write that run only on the server, but you can call them directly from your React components. No fetch calls, no axios, no setting up API endpoints, no manual JSON serialization. It honestly feels like magic, but it's just a brilliant abstraction.
If you need a mental model, think of it this way: API Routes are about exposing resources (REST). Server Actions are about exposing actions (RPC - Remote Procedure Call).
Your First Server Action
So, how do we make one? You define your function in a file and mark the entire file with the 'use server' directive at the very top. Or, you can define them inside a Server Component and just add 'use server' to the top of the function body itself.
Let's refactor our "create todo" logic into a Server Action. Personally, I like to create an actions.ts file right alongside my pages or components.
'use server';
import { revalidatePath } from 'next/cache';
// Again, imagine this is a real database.
const todos: { id: number; text: string; }[] = [];
export async function createTodo(formData: FormData) {
// We get the form data directly. No request body parsing!
const todoText = formData.get('text') as string;
if (!todoText.trim()) {
return { error: 'Todo text cannot be empty' };
}
// 1. Do the server-side work (e.g., save to database)
console.log('Adding new todo:', todoText);
const newTodo = { id: Date.now(), text: todoText };
todos.push(newTodo); // Simulate DB insert
// 2. Revalidate the data cache for the todos page
revalidatePath('/todos');
// 3. Optionally return a result
return { success: true, newTodo };
}
Okay, pause. Let's break down the few amazing things that just happened here:
'use server': This little string is the magic wand. It tells Next.js, "This code is off-limits for the browser. Compile it into a special endpoint that can be called securely from the client."formData: The function automatically receives theFormDatafrom the form that calls it. No need toawait request.json(). It’s just... there.revalidatePath: This is a total game-changer. After we add the new todo, how does our UI update? Instead of manually refetching data on the client, we just tell Next.js, "Hey, the data for the/todospage is stale now. Go get the fresh stuff on the next render." This is a core concept of the App Router's data caching model.
How Do You Even Call It?
This is the part that honestly blew my mind the first time I saw it. You just... pass the function to a <form> element's action prop.
Let's make a simple page to display our todos and add new ones.
import { createTodo } from './actions';
// This is a Server Component! It runs on the server.
export default async function TodosPage() {
// In a real app, you'd fetch existing todos from a database here.
const existingTodos = [
{ id: 1, text: 'Learn Route Handlers' },
{ id: 2, text: 'Explore Server Actions' },
];
return (
<main>
<h1>My Todos</h1>
{/* The Magic Form */}
<form action={createTodo}>
<input type="text" name="text" placeholder="Add a new todo" />
<button type="submit">Add Todo</button>
</form>
{/* Display existing todos */}
<ul>
{existingTodos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</main>
);
}
Just look at that <form> tag. action={createTodo}. We are literally passing our async, server-side function directly to a JSX prop. This is wild. When the user submits that form, Next.js automatically handles everything:
- It serializes the form data.
- It sends a POST request to a special, auto-generated endpoint for that specific Server Action.
- It executes our
createTodofunction on the server with that form data. - It even handles progressive enhancement—the form works perfectly even if JavaScript is disabled!
Notice what's missing? There's no onSubmit handler. No useState for loading states (though you can add them easily with useFormStatus). No useEffect. No fetch. It's just a form calling a function. We’ve just vaporized an entire layer of client-side boilerplate.
By the way, if you're wondering how to hook this up to a real database, our guide on connecting Prisma to Next.js is a perfect next step.
The Big Question: When to Use Which?
Alright, deep breath. We've seen both in action. Route Handlers are for building classic APIs. Server Actions are for calling server logic directly from your components. But I know what you're thinking—the lines can still feel a little blurry.
To clear things up, here's the mental model that I've landed on. I just ask myself one simple question:
Who, or what, is consuming this endpoint?
Use Next.js API Routes (Route Handlers) when...
- You're building an API for external consumption. If you need a mobile app, a third-party service, or another website to get data from your application, you need a stable, public, web-standard API. This is the prime use case for Route Handlers. They speak the universal language of HTTP and REST.
- You need to set up webhooks. Services like Stripe, GitHub, or Shopify will send data to a specific URL in your app. A Route Handler is the perfect way to create that stable endpoint to receive and process webhook events.
- Your frontend is completely decoupled. If you're building a Next.js backend but the frontend is, say, a native iOS app, a Vue app, or even a different Next.js app, you'll need to communicate via a standard API.
- You prefer the RESTful paradigm. If your brain is hardwired for
GET /users,POST /users,PUT /users/:id, and you want to maintain that clear separation of concerns, Route Handlers are your trusted friend.
Use Server Actions when...
- You're handling data mutations from your own Next.js app. This is the sweet spot. Forms for creating, updating, or deleting data are the number one use case. Think
createPost,updateUserSettings,deleteFromCart, etc. - You want to simplify your client-side code. If you find yourself writing
useStatefor loading,useStatefor error, and afetchcall inside anonSubmithandler... you should probably be using a Server Action. It co-locates the mutation logic with the component that triggers it. - You want progressive enhancement for free. Because Server Actions work with standard HTML forms, your site remains functional even without JavaScript. This is a huge win for accessibility and resilience.
- You're triggering a server-only task from a client event. Imagine a button that says "Generate Report." You can wire that button's
onClickto a Server Action (with a little help from thestartTransitionhook) that kicks off a heavy process on the server without needing a formal API endpoint.
My rule of thumb: If the "API call" is just your own React component talking to your own server, start with a Server Action. It's simpler, more direct, and eliminates a ton of boilerplate. If something outside your web app needs to talk to your server, build a Route Handler.
Don't Forget Security!
Okay, before we wrap up, we need to have a serious talk about security. Whether you're using API Routes or Server Actions, you're running powerful code on a server that can access your database and other secrets. Security isn't just a feature; it's a prerequisite.
With Route Handlers, you'd typically protect them by checking for a session token or API key in the request headers or cookies.
import { getSession } from 'next-auth/react'; // or your auth provider
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const session = await getSession({ req: request }); // Example
if (!session || !session.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Proceed with protected logic
return NextResponse.json({ data: 'This is protected data!' });
}
With Server Actions, it's the same idea. You just perform the check right inside the action's body.
'use server';
import { auth } from '@/lib/auth'; // Your auth setup
export async function deleteUserAccount() {
const session = await auth();
if (!session?.user?.id) {
return { error: 'Unauthorized. You must be logged in.' };
}
// Now, safely delete the user's account
// await db.deleteUser(session.user.id);
console.log(`Deleting account for user: ${session.user.id}`);
return { success: 'Account deleted successfully.' };
}
The principle here is timeless and universal: never, ever trust the client. Always, always verify authentication and authorization on the server before performing any sensitive operation. The official Next.js security documentation is an absolute must-read.
The Final Verdict
So, what's the final verdict? Are Server Actions here to replace API Routes? Nope. Absolutely not.
Think of them as two different tools for two different—though sometimes overlapping—jobs. An API Route is like a hammer for building sturdy, standards-compliant APIs. A Server Action is like a nail gun for rapidly and directly connecting your UI to your server logic.
Honestly, any mature Next.js application will probably end up using both. You'll have your sturdy Route Handlers for your public API, webhooks, and cross-domain needs. And you'll sprinkle Server Actions all over your UI for forms, button clicks, and any other client-to-server mutations.
The real magic here isn't one tool being 'better' than the other; it's that we now have the choice. We can pick the perfect tool for the job, and in the process, write cleaner, more maintainable, and more powerful full-stack applications than ever before. Welcome to the blurry, beautiful future.
Frequently Asked Questions
Can I call a Server Action from outside my Next.js app, like a regular API?
That's a great question, but the short answer is no, not really. Server Actions are specifically designed for that tight, cozy integration with your Next.js frontend via a specific RPC protocol. While they are technically POST endpoints, they have a specific encoding and are not meant to be used as general-purpose REST endpoints. If you need something for the outside world to call, a Route Handler is definitely the way to go.
What about error handling in Server Actions?
It's actually pretty elegant! On the server, you can just wrap your logic in a good old
try...catchblock. Then, back on the client, you can use theuseFormStatehook to gracefully receive and display any errors returned from the action without a full page reload. It's as simple as returning an object like{ error: 'Whoops, that did not work' }from your action and checking for it on the client.
Are API Routes (Route Handlers) slower than Server Actions?
For most apps, any performance difference is going to be so tiny you'd likely never notice it. The real difference is in the developer experience and how you organize your code. Server Actions often feel faster to develop because they let you skip so much boilerplate. At the end of the day, both involve a network request, so the latency is comparable.
Can I use both in the same project?
Yes, 100%! In fact, you absolutely should. Please don't think of this as an either/or choice. A healthy, robust Next.js application will almost certainly benefit from using both. Use Route Handlers for your app's public API surface and webhooks, and use Server Actions for your internal UI mutations. They complement each other perfectly.