Use a Server Action when a form or button inside your Next.js application performs an app-owned mutation. Use a Route Handler when another system needs a stable HTTP endpoint: a webhook, mobile app, public API, health check, or file response.
Neither option is automatically secure. Next.js recommends authenticating and authorizing inside every Server Action and treating Route Handlers like public API endpoints.
Decision table
| Requirement | Server Action | Route Handler |
|---|---|---|
| Form submitted by your React UI | Usually best | Works, but adds manual fetch code |
| Stripe or GitHub webhook | No | Yes |
| Mobile or third-party client | No | Yes |
| Explicit HTTP method and status | Limited abstraction | Full control |
| Return JSON, XML, image, or file | Not the main use | Yes |
| Revalidate UI after a mutation | Convenient | Manual client flow |
| Progressive-enhancement form | Strong fit | Requires client request logic |
The deciding question is who calls the code. If the caller is your rendered app, a Server Action can remove boilerplate. If the caller only knows HTTP, use a Route Handler.
Server Action example: update a profile
// app/settings/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { requireUser } from '@/lib/auth';
import { db } from '@/lib/db';
const inputSchema = z.object({
displayName: z.string().trim().min(2).max(60)
});
export async function updateProfile(formData: FormData) {
const user = await requireUser();
const input = inputSchema.parse({
displayName: formData.get('displayName')
});
await db.user.update({
where: { id: user.id },
data: input
});
revalidatePath('/settings');
}
// app/settings/profile-form.tsx
import { updateProfile } from './actions';
export function ProfileForm() {
return (
<form action={updateProfile}>
<label htmlFor="displayName">Display name</label>
<input id="displayName" name="displayName" required />
<button type="submit">Save</button>
</form>
);
}
The action is close to the UI, but the authorization check still belongs inside the action. Hiding the button from unauthorized users is not protection.
Route Handler example: receive a webhook
A webhook caller needs a URL, request body, headers, and a meaningful status code.
// app/api/webhooks/payments/route.ts
import { headers } from 'next/headers';
import { verifyPaymentEvent } from '@/lib/payments';
import { processPaymentEvent } from '@/lib/payment-events';
export async function POST(request: Request) {
const signature = (await headers()).get('payment-signature');
const rawBody = await request.text();
if (!signature) {
return Response.json({ error: 'missing signature' }, { status: 400 });
}
const event = verifyPaymentEvent(rawBody, signature);
await processPaymentEvent(event);
return new Response(null, { status: 204 });
}
Reading the raw body before JSON parsing is important for providers that sign exact request bytes. The handler can return 400, 401, 409, or 204 according to the HTTP contract.
Route Handlers are better for shared contracts
Choose a Route Handler when you need one or more of these:
- A mobile app or external integration calls the endpoint.
- The endpoint is documented with OpenAPI.
- Consumers depend on status codes and response headers.
- The route returns a non-UI format such as JSON, XML, or a file.
- A webhook provider retries requests.
- You need standard HTTP caching behavior.
A Route Handler can still call the same service function used by a Server Action. Keep business logic below the transport layer instead of duplicating it.
// lib/users/update-display-name.ts
export async function updateDisplayName(userId: string, name: string) {
return db.user.update({
where: { id: userId },
data: { displayName: name }
});
}
Now the web form and API can share validation and domain behavior while keeping different request contracts.
Security rules for both
| Risk | Required control |
|---|---|
| User changes another user’s data | Authorize the resource, not only the session |
| Invalid form or JSON input | Parse with a schema on the server |
| Duplicate webhook delivery | Use an event ID or idempotency key |
| Secret leaks in errors | Return a safe message; log details server-side |
| Expensive operation abuse | Add rate limits and workload limits |
| Cross-origin Server Action use | Keep allowed origins narrow |
Server-side execution does not make client input trustworthy. Every identifier, field, and permission must still be checked.
A common architecture mistake
Do not put database logic directly in five Server Actions and three Route Handlers. That creates eight slightly different implementations of the same rule.
Use this shape:
UI form -> Server Action -> application service -> database
Webhook -> Route Handler -> application service -> database
Mobile app -> Route Handler -> application service -> database
The transport validates request-specific details. The application service enforces the business rule. This also makes unit tests independent of Next.js request objects.
Practical choice
Use Server Actions for app-owned form mutations where their UI integration saves code. Use Route Handlers whenever the HTTP contract is part of the feature. If both callers need the same operation, share a service underneath them.
Discussion
What would you try, change, or challenge after reading this guide? Specific results and errors help the next reader.
Comments will load as you reach this section.