Migrating from API Routes to Server Actions in Next.js
Learn how to migrate your Next.js API routes to Server Actions for better performance, type safety, and developer experience. A complete migration guide with real examples and best practices.
Matias Facello

Introduction
If you've been building with Next.js for a while, you've probably used API routes for handling form submissions, data mutations, and server-side logic. They work, but they come with their fair share of complexity: managing request/response objects, handling different HTTP methods, dealing with CORS, and writing boilerplate code for every endpoint.
Server Actions changed all that. They're Next.js's way of letting you run server-side code directly from your components without writing API routes. No more fetch calls, no more manual state management, no more wrestling with request/response objects.
In this post, I'll walk you through why I migrated my API routes to Server Actions, when you should (and shouldn't) make the switch, and how to do it step by step: real examples, common gotchas, and lessons from a real migration.
Why Server Actions instead of API Routes?
The biggest win is simplicity.
Server Actions remove the need for API routes in most use cases. You write server-side functions that your components can call directly, and Next.js handles the networking, serialization, and state management for you.
From a developer experience perspective, it's night and day. No more fetch() calls, no more manual error handling, no more keeping track of loading states. The useActionState hook gives you the current state and the action wiring, and you can trigger revalidation when your data/cache needs it.
Type safety is another huge benefit. Since Server Actions are just TypeScript functions, you get type inference from server to client. No more guessing what the API response looks like or dealing with any types.
Performance-wise, Server Actions can feel leaner: there's no CORS preflight, no separate JSON serialization layer you write yourself, and server code never ships to the client bundle. The network request still happens, but Next.js handles all the wiring between the UI and your server code.
When NOT to use Server Actions
Server Actions aren't a silver bullet. There are still cases where API routes make more sense.
If you need to expose endpoints for external services, mobile apps, or third-party integrations, stick with API routes. Server Actions are designed for internal use within your Next.js app.
If you need fine-grained control over HTTP methods, status codes, or headers, API routes give you more flexibility. Server Actions abstract away these details, which is usually good, but sometimes you need that control.
For streaming responses or when you need fine-grained control over the response body, API routes are the right tool. Server Actions can accept file uploads through FormData, but they don't support streaming data back to the client.
Here's how they compare at a glance: ✅ advantage, ❌ disadvantage, ➖ depends on context.
| Server Actions | API Routes | |
|---|---|---|
| Boilerplate | ✅ Minimal, no request/response objects | ❌ More verbose, explicit by design |
| Type safety | ✅ End-to-end, server function to component | ❌ Manual, you type the response shape yourself |
| Client state | ✅ useActionState handles it for you | ❌ useState / useReducer wired manually |
| Loading state | ✅ useFormStatus out of the box | ❌ Manual isLoading flag |
| Revalidation | ✅ revalidatePath / revalidateTag built-in | ❌ Manual cache invalidation |
| CORS | ✅ Never a concern | ❌ Must be handled when exposing to external clients |
| HTTP control | ➖ Abstracted away: simpler, but less flexible | ➖ Full control: more powerful, more work |
| Framework coupling | ➖ Next.js only, fine if you're committed | ➖ Portable across any Node.js framework |
| External consumers | ❌ Internal use only | ✅ Mobile apps, third-party, webhooks |
| Streaming responses | ❌ Not supported | ✅ Supported via ReadableStream |
The Migration Process
Let me show you how I migrated a contact form from API routes to Server Actions. This is a real example from my blog.
Before: API Route Approach
Here's what the old API route looked like:
// src/app/api/contact/route.ts import { NextRequest, NextResponse } from "next/server"; import { contactFormSchema } from "~/lib/schemas/contact"; export async function POST(request: NextRequest) { try { const body = await request.json(); // Validate with Zod const parsed = contactFormSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Validation failed", details: parsed.error.issues }, { status: 400 }, ); } // Send email (replace with your provider: Resend, Nodemailer, SendGrid, etc.) const { error } = extraFunctionToSendTheEmail(); if (error) { return NextResponse.json( { error: "Failed to send email" }, { status: 500 }, ); } return NextResponse.json({ success: true }); } catch (error) { return NextResponse.json( { error: "Internal server error" }, { status: 500 }, ); } }
And here's how the component used it:
// src/components/contact/contactForm.tsx "use client"; import { useState } from "react"; export default function ContactForm() { const [isLoading, setIsLoading] = useState(false); const [message, setMessage] = useState(""); const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); setIsLoading(true); try { const formData = new FormData(e.currentTarget); const response = await fetch("/api/contact", { method: "POST", body: JSON.stringify({ name: formData.get("name"), email: formData.get("email"), subject: formData.get("subject"), message: formData.get("message"), }), headers: { "Content-Type": "application/json", }, }); const result = await response.json(); if (response.ok) { setMessage("Message sent successfully!"); e.currentTarget.reset(); } else { setMessage(result.error || "Something went wrong"); } } catch (error) { setMessage("Something went wrong"); } finally { setIsLoading(false); } }; return ( <form onSubmit={handleSubmit}> {/* form fields */} <button type="submit" disabled={isLoading}> {isLoading ? "Sending..." : "Send Message"} </button> {message && <p>{message}</p>} </form> ); }
After: Server Actions Approach
Now here's the same functionality using Server Actions:
// src/app/actions/contact.ts "use server"; import { contactFormSchema } from "~/lib/schemas/contact"; export type ContactActionState = { status: "idle" | "success" | "error"; message?: string; fieldErrors?: Record<string, string>; }; export async function submitContactForm( _prevState: ContactActionState, formData: FormData, ): Promise<ContactActionState> { try { // Extract form data const name = (formData.get("name") ?? "").toString(); const email = (formData.get("email") ?? "").toString(); const subject = (formData.get("subject") ?? "").toString(); const message = (formData.get("message") ?? "").toString(); // Validate with Zod const parsed = contactFormSchema.safeParse({ name, email, subject, message, }); if (!parsed.success) { const fieldErrors: Record<string, string> = {}; for (const issue of parsed.error.issues) { const key = String(issue.path[0] ?? ""); if (key && !fieldErrors[key]) fieldErrors[key] = issue.message; } return { status: "error", message: "Validation error", fieldErrors }; } // Send email (replace with your provider: Resend, Nodemailer, SendGrid, etc.) const { error } = extraFunctionToSendTheEmail(); if (error) { return { status: "error", message: "Failed to send email" }; } return { status: "success", message: "Message sent successfully!" }; } catch (error) { return { status: "error", message: "Something went wrong" }; } }
And the component becomes much simpler:
// src/components/contact/contactForm.tsx "use client"; import { useActionState } from "react"; import { useFormStatus } from "react-dom"; import { submitContactForm } from "~/app/actions/contact"; function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? "Sending..." : "Send Message"} </button> ); } export default function ContactForm() { const [state, formAction] = useActionState(submitContactForm, { status: "idle", }); return ( <form action={formAction}> {/* form fields */} <SubmitButton /> {state.message && <p>{state.message}</p>} </form> ); }
Look at that difference! The component file went from 45+ lines to under 30, and the form component itself shrank to a fraction of that. No more manual state management, no more fetch calls, no more error handling boilerplate.
Key Patterns and Best Practices
1. Action State Types
Always define a clear state type for your actions:
export type ContactActionState = { status: "idle" | "success" | "error"; message?: string; fieldErrors?: Record<string, string>; };
This gives you type safety and makes it clear what states your action can be in.
2. Form Data Handling
Server Actions receive FormData directly: no JSON parsing, no request.body, no extra serialization step. Call .get("fieldName") on it, and always coerce with ?? "" and .toString() since formData.get() returns FormDataEntryValue | null, which Zod won't accept without coercion.
3. Validation with Zod
I use Zod for validation in both API routes and Server Actions, but it's even cleaner with Server Actions:
const parsed = contactFormSchema.safeParse({ name: (formData.get("name") ?? "").toString(), email: (formData.get("email") ?? "").toString(), }); if (!parsed.success) { const fieldErrors: Record<string, string> = {}; for (const issue of parsed.error.issues) { const key = String(issue.path[0] ?? ""); if (key && !fieldErrors[key]) fieldErrors[key] = issue.message; } return { status: "error", fieldErrors }; }
Displaying field-level errors
If you build fieldErrors from Zod issues, you can render them next to inputs so users get immediate feedback on what went wrong.
// Inside your ContactForm component <input name="email" aria-invalid={!!state.fieldErrors?.email} />; { state.fieldErrors?.email && <p>{state.fieldErrors.email}</p>; }
Make sure your form inputs use name attributes that match the keys your Zod schema expects.
4. Error Handling
Server Actions make error handling much more straightforward:
"use server"; export async function myAction( _prevState: ActionState, formData: FormData, ): Promise<ActionState> { try { // Your logic here return { status: "success", message: "Done!" }; } catch (error) { console.error("Action error:", error); return { status: "error", message: "Something went wrong. Please try again.", }; } }
5. Loading States
For the “pending” UI state, use useFormStatus() (this is what you want for disabling the submit button / showing a spinner).
function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? "Sending..." : "Send"} </button> ); }
Advanced Patterns
Tracking action outcomes (recommended)
Instead of wrapping server actions for analytics, a simpler and safer approach is to track in the UI when the action result changes.
// Assumes you have a client-side helper like: trackEvent(name, data?) useEffect(() => { if (state.status === "success") trackEvent("form_success"); if (state.status === "error") trackEvent("form_error", { error: state.message }); }, [state.status, state.message]);
Rate Limiting
Server Actions work great with rate limiting:
"use server"; import { headers } from "next/headers"; import { checkRateLimit } from "~/lib/rateLimit"; export async function submitForm( _prevState: ActionState, formData: FormData, ): Promise<ActionState> { const hdrs = await headers(); const ip = hdrs.get("x-forwarded-for") || "unknown"; // checkRateLimit(identifier, windowMs, maxRequests) if (!checkRateLimit(ip, 15 * 60 * 1000, 5)) { return { status: "error", message: "Too many requests. Please try again later.", }; } // Continue with your logic... }
Migration Checklist
Here's a step-by-step checklist for migrating your API routes:
- Identify candidates: Look for API routes that handle form submissions or simple data mutations
- Create the Server Action: Move your logic to a new file in
src/app/actions/ - Define state types: Create TypeScript types for your action state
- Update the component: Replace fetch calls with
useActionState - Test thoroughly: Make sure error handling and validation work correctly
- Remove the API route: Delete the old API route file
- Update any external references: If other services call your API, keep the existing route or create a dedicated API route for external consumers
Security and Reliability Checks
- Validate and authorize inside the Server Action (never trust the client)
- Keep input validation consistent (Zod schemas are great for this)
- Make mutations idempotent when possible (so retries don't create duplicates)
- For slow or side-effect-heavy work (emails, webhooks), consider offloading to a queue/background job instead of blocking the request
Common Gotchas
1. Form Data vs JSON
Server Actions work with FormData, not JSON. If you're used to sending JSON in API routes, you'll need to adjust:
// Instead of this (API route style): const body = await request.json(); // Do this (Server Action style): const name = (formData.get("name") ?? "").toString();
2. Error Boundaries
Server Actions run on the server, so you typically won't rely on React Error Boundaries the same way you'd handle client rendering errors. The practical approach is to catch failures inside the action and return a state you can render, which is exactly the pattern shown in the Error Handling section above.
3. Revalidation
In many form-action flows, Next will refresh the UI for you. If you're working with cached data or you need more control, use revalidatePath or revalidateTag:
"use server"; import { revalidatePath } from "next/cache"; export async function updatePost(formData: FormData) { // Update logic... revalidatePath("/blog"); return { status: "success" }; }
What I Learned Along the Way
The biggest surprise was how much the component simplified. I expected the server side to be cleaner, but it was the form component shrinking from 40+ lines to under 30 that felt like the real win. That's where you feel the difference day to day.
Define your state types before you write any action logic. Once you can see the shape of what your action returns, everything else falls into place: the component, the error display, the loading state.
Don't rush deleting the old API routes. Run both in parallel for a day or two, compare behavior, then remove. I had one route with edge-case header handling I'd forgotten about and only with usage it became clear I needed to adjust the Server Action logic.
Wrapping Up
Server Actions have made a real difference in my Next.js apps. They've eliminated so much boilerplate and made the developer experience noticeably better. The migration was worth it just for the reduced complexity alone.
Remember: Good abstractions should make your code simpler, not more complex.
If you're still using API routes for simple form submissions and data mutations, give Server Actions a try. The learning curve is gentle, and the benefits are immediate.
And if you've already made the switch, I'd love to hear about your experience and any patterns you've discovered.
Happy coding!
Matias
Table of Contents