Debugging Next.js Server Actions: Deploy Errors, CORS, and Payload Limits
A practical troubleshooting guide covering why Next.js Server Actions and API routes fail silently or throw cryptic errors, with concrete fixes for each case.
This article is published in English.
Sources: Common Server Action and API Route Errors in Next.js (and How to Fix Them)
Server Actions took a lot of the friction out of backend logic in Next.js — you no longer need a dedicated API route just to handle a form submission. But this convenience comes with its own set of failure modes, ones that look nothing like the bugs you'd hit in the old Pages Router world. Here's a rundown of what tends to go wrong and how to address it.
1. "Error: Failed to find Server Action" After a Deploy
Everything runs fine on your machine, then breaks the moment a new version ships — users mid-session suddenly hit this error.
Cause: Server Actions get compiled with IDs tied to a specific build. If someone has a browser tab open while you deploy, their client keeps referencing action IDs from the old build, and the server no longer recognizes them.
Fix: you can't completely prevent this for users already active during a deploy, but you can handle it more gracefully instead of letting it crash silently:
'use client';
import { useTransition } from 'react';
function SubmitButton({ action }) {
const [isPending, startTransition] = useTransition(); async function handleClick() {
try {
await action();
} catch (err) {
if (err.message?.includes('Failed to find Server Action')) {
window.location.reload(); // force a fresh client with current build IDs
}
}
} return <button onClick={() => startTransition(handleClick)}>Submit</button>;
}
Catching this specific failure and nudging the user to refresh is a much better experience than a raw, unexplained crash.
2. Server Action Silently Not Running
You invoke the action, nothing happens, and there's no error printed anywhere.
Common cause: missing the 'use server' directive at the top of the function, or writing the action inside a Client Component without marking it properly.
// Wrong — no directive
async function saveUser(formData) {
await db.user.update(/* ... */);
}
Fix:
'use server';
export async function saveUser(formData) {
await db.user.update(/* ... */);
}
If the action is defined in the same file as a Client Component, the directive needs to sit at the top of the function itself, not merely at the top of the file:
'use client';
export default function Form() {
async function saveUser(formData) {
'use server'; // required here specifically
await db.user.update(/* ... */);
}
return <form action={saveUser}>{/* ... */}</form>;
}
3. "Error: Only plain objects can be passed to Client Components"
This fires when a Server Action's return value isn't serializable — for example a class instance, a Date buried inside an unusual structure, or a database model that carries its own methods.
'use server';
export async function getUser(id) {
return await db.user.findUnique({ where: { id } }); // ORM might return a class instance
}
Fix — manually reshape the result into a plain object:
'use server';
export async function getUser(id) {
const user = await db.user.findUnique({ where: { id } });
return {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(), // Dates need explicit serialization too
};
}
Don't hand an ORM result straight to a Client Component. Convert it into a plain, serializable object first.
4. Server Action Works, But Form Data Comes Back undefined
<form action={createPost}>
<input name="title" />
</form>
'use server';
export async function createPost(formData) {
console.log(formData.title); // undefined
}
Cause: the formData argument you receive is a native FormData instance, not a regular JavaScript object, so you must call .get() to pull values out of it.
Fix:
'use server';
export async function createPost(formData) {
const title = formData.get('title');
console.log(title); // correct
}
5. "Body exceeded 1mb limit" on Server Actions
Server Actions enforce a default cap on request body size, which becomes a problem the moment you're dealing with file uploads or bulky form payloads.
Fix — increase the limit explicitly inside next.config.js:
// next.config.js
module.exports = {
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
};
For anything resembling a real file upload, it's usually better to skip routing the file through the Server Action entirely. Upload straight from the client to object storage — S3, R2, or similar — using a signed URL. That sidesteps the size ceiling and keeps the load off your server.
6. CORS Errors on API Routes Called From a Different Origin
This one tends to show up when a mobile app, a browser extension, or a separate frontend hits your Next.js API routes directly instead of going through your own pages.
Access to fetch at 'https://yourapp.com/api/data' from origin 'https://otherapp.com' has been blocked by CORS policy
Fix — set the headers explicitly inside the route handler:
// app/api/data/route.js
export async function GET(request) {
const data = await getData();
return Response.json(data, {
headers: {
'Access-Control-Allow-Origin': 'https://otherapp.com', // never '*' for authenticated routes
'Access-Control-Allow-Methods': 'GET, POST',
},
});
}
export async function OPTIONS() {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': 'https://otherapp.com',
'Access-Control-Allow-Methods': 'GET, POST',
},
});
}
Avoid setting Access-Control-Allow-Origin: '*' on any endpoint that relies on authentication cookies. Doing so effectively hands user data to whatever origin decides to ask for it.
7. Route Handler Returns HTML Instead of JSON on Error
When a route handler throws an unhandled exception, the client doesn't get a tidy JSON error back — it gets Next.js's built-in HTML error page instead. That's a problem for any client code that assumes it can safely call response.json().
Fix — wrap the handler logic in a try/catch that always sends JSON back:
export async function POST(request) {
try {
const body = await request.json();
const result = await processOrder(body);
return Response.json(result);
} catch (error) {
return Response.json(
{ error: error.message ?? 'Something went wrong' },
{ status: 500 }
);
}
}
With this in place, the client always receives something parseable as JSON, whether the request succeeded or failed, rather than an HTML document that blows up your .json() call further down the line.
Why These Errors Feel Worse Than Old-Style API Route Bugs
Server Actions collapse client and server logic into the same file, which makes it surprisingly easy to cross a boundary without noticing. A return value can look perfectly fine in your editor and still blow up at runtime because it isn't serializable, or a missing directive can fail quietly instead of throwing where you'd expect. The usual debugging habits from REST-style APIs — open the network tab, inspect the response — don't always help here, because several of these failures happen before any network request is even dispatched.
For teams building dashboards full of forms, file uploads, and mutations spread across many pages, it's worth having these patterns — serialization, error handling, body size limits — solved consistently once across the codebase, rather than re-debugging the same issues in every new form. Some prebuilt admin templates for AI-powered SaaS products already ship with Server Actions and API routes wired up correctly, including validation through Zod and proper error handling baked in from the start, so these seven issues get addressed a single time instead of resurfacing in every form you write.
Bottom Line
Server Actions cut down on a lot of the boilerplate that API routes used to require, but they brought their own set of failure modes along with them: build IDs going stale after a deploy, missing directives, return values that can't be serialized, misuse of FormData, body size ceilings, cross-origin CORS failures, and error responses that aren't really JSON. All seven show up often, all seven can be fixed in just a few lines, and all seven deserve a quick check before you assume something more complicated is going wrong.