Fix: Next.js Server Action Not Working — Action Not Called or Returns Error
Part of: React & Frontend Errors
Quick Answer
How to fix Next.js Server Actions — use server directive, form binding, revalidation, error handling, middleware conflicts, and client component limitations.
The Problem
A Next.js Server Action doesn’t execute:
// app/actions.ts
'use server';
export async function createUser(formData: FormData) {
const name = formData.get('name');
await db.user.create({ data: { name } });
}
// app/page.tsx
<form action={createUser}>
<input name="name" />
<button type="submit">Create</button>
</form>
// Submitting the form does nothing — no network request, no errorOr the action runs but data doesn’t refresh:
// Action runs successfully (console.log shows the data)
// But the UI doesn't update — old data still showingOr a Server Action throws in a Client Component:
// Error: Server actions must be async functions
// Error: Failed to find Server Action "xxxx"
// Error: Functions cannot be passed directly to Client ComponentsWhy This Happens
Server Actions are an App Router feature built on React’s “use server” directive. From the client’s perspective, calling a Server Action does not invoke a function — it triggers a POST to a generated endpoint that Next.js wires up at build time. The compiler scans your code for the 'use server' directive, extracts those functions into a separate server bundle, and rewrites every reference in the client bundle to a stub that serializes arguments and posts them to the action endpoint.
This compile-time rewrite is the source of most silent failures. If the directive is in the wrong place, missing, or shadowed by a 'use client' boundary above it, the compiler does not see a Server Action and emits nothing. The function still appears to exist in your source code, but at runtime it either runs on the client (and crashes when it touches a database) or never runs at all (because the form submission posts to a non-existent endpoint and the page silently 404s in the network tab without throwing).
The other major source of confusion is caching. Next.js 14 and 15 ship with aggressive default caching for fetch, route segments, and the Router Cache itself. A Server Action can mutate the database successfully and still appear to “not work” because the page rendering the data is being served from cache.
- Missing
'use server'directive — the directive must be at the top of the file (for a module of server actions) or at the top of the function body. Without it, the function is treated as a regular client-side function. - Action not revalidating cache — Next.js aggressively caches. After a Server Action mutates data, you must call
revalidatePath()orrevalidateTag()to refresh the cache and trigger a UI update. - Passing Server Actions to Client Components — a Server Action defined in a Server Component can be passed as a prop to a Client Component, but only if defined in a separate
'use server'file, not inline. useFormState/useActionStatenot set up correctly — for progressive enhancement with error handling, these hooks have specific binding requirements.- Middleware interfering — authentication middleware that returns early before the Server Action endpoint is reached blocks the action.
- Actions only work in App Router — Server Actions are not available in the Pages Router (
pages/).
Version History That Changes the Failure Mode
Server Actions evolved rapidly across Next.js 13, 14, and 15. The exact API and defaults change between minor versions, so a working pattern from an older tutorial may produce errors on a newer release.
- Next 13.4 (May 2023) — Server Actions shipped as an experimental feature. Required
experimental: { serverActions: true }innext.config.jsand only worked with<form>(not direct invocation). Many tutorials from this era still reference the experimental flag, which now triggers a warning. - Next 13.5 (Sep 2023) — relaxed the form-only restriction, allowing Server Actions to be called from
useTransitionand event handlers. This is when “Server Actions in Client Components” started working with imports rather than inline definitions. - Next 14.0 (Oct 2023) — Server Actions promoted to stable. The
experimentalflag was removed.useFormState(fromreact-dom) was the canonical hook for progressive enhancement. - Next 14.1 (Jan 2024) — added improvements to form action error handling and
redirect()interop. Body size limit raised from 1MB to a configurableserverActions.bodySizeLimit. - Next 15.0 (Oct 2024) — flipped caching defaults:
fetchis no longer cached by default, route segments use dynamic rendering unless opted in, and the Router Cache no longer holds page segments after navigation.useFormStatewas renamed touseActionStateand moved fromreact-domtoreact(React 19). Async cookies/headers APIs requireawaitcalls. - Next 15.2 (Feb 2026 era backports) — improved server action allowed-origins enforcement. If you deploy behind a proxy with a different
Hostheader, actions return 403 unless you configureserverActions.allowedOrigins.
Run npx next --version and compare against the table above before debugging.
Fix 1: Add the Correct ‘use server’ Directive
The 'use server' directive must appear at the top of the file or function:
// Option 1 — Module-level directive (entire file exports Server Actions)
// app/actions.ts
'use server'; // ← Must be the very first line
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function createUser(formData: FormData) {
const name = formData.get('name') as string;
await db.user.create({ data: { name } });
revalidatePath('/users');
}
export async function deleteUser(id: string) {
await db.user.delete({ where: { id } });
revalidatePath('/users');
}// Option 2 — Inline directive in Server Components
// app/page.tsx (Server Component — no 'use client')
export default function Page() {
async function createUser(formData: FormData) {
'use server'; // ← Inside the function body
const name = formData.get('name') as string;
await db.user.create({ data: { name } });
revalidatePath('/users');
}
return (
<form action={createUser}>
<input name="name" />
<button type="submit">Create</button>
</form>
);
}WRONG — no directive:
// WRONG — looks like a server action but isn't
export async function createUser(formData: FormData) {
// No 'use server' directive — runs on client, can't access db
await db.user.create({ data: { name } }); // db not available on client
}Fix 2: Revalidate After Mutations
After data changes, tell Next.js to invalidate the cache:
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const post = await db.post.create({ data: { title } });
// Revalidate specific paths
revalidatePath('/posts'); // Revalidate the posts list page
revalidatePath(`/posts/${post.id}`); // Revalidate the new post page
// Or revalidate by cache tag (if you tagged fetches)
revalidateTag('posts');
// Redirect after successful creation
redirect(`/posts/${post.id}`);
}
export async function updatePost(id: string, formData: FormData) {
const title = formData.get('title') as string;
await db.post.update({ where: { id }, data: { title } });
revalidatePath('/posts');
revalidatePath(`/posts/${id}`);
}Tag-based revalidation for fine-grained cache control:
// app/posts/page.tsx — fetch with cache tag
const posts = await fetch('/api/posts', {
next: { tags: ['posts'] }
}).then(r => r.json());
// In Server Action — invalidate by tag
revalidateTag('posts'); // All fetches tagged 'posts' are revalidatedFix 3: Use Server Actions in Client Components
Server Actions defined in 'use server' files can be imported into Client Components:
// app/actions.ts — separate server actions file
'use server';
export async function createUser(formData: FormData) {
await db.user.create({ data: {
name: formData.get('name') as string,
}});
revalidatePath('/users');
}// app/components/UserForm.tsx — Client Component
'use client';
import { createUser } from '@/app/actions'; // Import server action
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus(); // Shows loading state during action
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create User'}
</button>
);
}
export function UserForm() {
return (
<form action={createUser}>
<input name="name" placeholder="Name" required />
<SubmitButton />
</form>
);
}WRONG — defining inline Server Action inside a Client Component:
// WRONG — can't define 'use server' inside a Client Component
'use client';
export function UserForm() {
async function createUser(formData: FormData) {
'use server'; // Error: Server Actions cannot be defined inside Client Components
await db.user.create(...);
}
return <form action={createUser}>...</form>;
}Fix 4: Error Handling with useActionState
Handle Server Action errors and show feedback to users:
// app/actions.ts
'use server';
type ActionState = {
error?: string;
success?: boolean;
};
export async function createUser(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const name = formData.get('name') as string;
if (!name || name.trim().length < 2) {
return { error: 'Name must be at least 2 characters' };
}
try {
await db.user.create({ data: { name } });
revalidatePath('/users');
return { success: true };
} catch (err) {
return { error: 'Failed to create user. Please try again.' };
}
}// app/components/UserForm.tsx
'use client';
import { useActionState } from 'react'; // React 19 / Next.js 14+
import { createUser } from '@/app/actions';
export function UserForm() {
const [state, formAction, isPending] = useActionState(createUser, {});
return (
<form action={formAction}>
{state.error && (
<p className="text-red-500">{state.error}</p>
)}
{state.success && (
<p className="text-green-500">User created successfully!</p>
)}
<input name="name" placeholder="Name" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create User'}
</button>
</form>
);
}Note:
useActionStatewas added in React 19. In Next.js 14 with React 18, useuseFormStatefromreact-dominstead (same API, different import).
Fix 5: Programmatic Invocation (Without Forms)
Server Actions can be called programmatically, not just via form submission:
// app/components/DeleteButton.tsx
'use client';
import { deleteUser } from '@/app/actions';
import { useTransition } from 'react';
export function DeleteButton({ userId }: { userId: string }) {
const [isPending, startTransition] = useTransition();
function handleDelete() {
startTransition(async () => {
await deleteUser(userId);
});
}
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? 'Deleting...' : 'Delete'}
</button>
);
}With optimistic updates:
'use client';
import { useOptimistic } from 'react';
import { deleteUser } from '@/app/actions';
export function UserList({ users }: { users: User[] }) {
const [optimisticUsers, removeOptimisticUser] = useOptimistic(
users,
(state, userId: string) => state.filter(u => u.id !== userId)
);
async function handleDelete(userId: string) {
removeOptimisticUser(userId); // Instant UI update
await deleteUser(userId); // Actual server mutation
}
return (
<ul>
{optimisticUsers.map(user => (
<li key={user.id}>
{user.name}
<button onClick={() => handleDelete(user.id)}>Delete</button>
</li>
))}
</ul>
);
}Fix 6: Authentication in Server Actions
Protect Server Actions from unauthorized use:
// app/actions.ts
'use server';
import { auth } from '@/lib/auth'; // Your auth solution (NextAuth, Clerk, etc.)
import { redirect } from 'next/navigation';
export async function createPost(formData: FormData) {
// Verify authentication inside the action — don't rely on middleware alone
const session = await auth();
if (!session?.user) {
redirect('/login');
// Or: throw new Error('Unauthorized');
}
const title = formData.get('title') as string;
await db.post.create({
data: {
title,
authorId: session.user.id, // Use verified session data
},
});
revalidatePath('/posts');
}Warning: Never trust data from
formDatafor sensitive operations like setting theauthorId. Always use the server-side session to determine who’s performing the action. A malicious client could send any value in a hidden form field.
Still Not Working?
Server Actions require Next.js 14+ App Router — they don’t work in the pages/ directory or with Next.js 13 without experimental flags. Verify your next.config.js:
// next.config.js — Server Actions are stable in Next.js 14+
// No configuration needed. In Next.js 13, they required:
// experimental: { serverActions: true }redirect() inside try-catch — redirect() throws internally. If you catch all errors, you’ll catch the redirect too:
// WRONG — redirect gets caught
try {
await db.post.create(...);
redirect('/posts'); // Throws internally
} catch (err) {
// This catches the redirect!
return { error: 'Something went wrong' };
}
// CORRECT — redirect outside try-catch
let redirectPath: string | null = null;
try {
await db.post.create(...);
redirectPath = '/posts';
} catch (err) {
return { error: 'Failed to create post' };
}
if (redirectPath) redirect(redirectPath);Action endpoint 404 in development — if you rename or move a Server Action file, the client may have a cached reference to the old endpoint URL. Hard-refresh the browser (Ctrl+Shift+R) to clear the client-side module cache.
403 Invalid Server Action signature after deploy — Next.js signs Server Action IDs with a build-time secret. If two deployments run side-by-side (e.g., during a rolling release), a client that loaded HTML from build A can submit to action endpoint B and get rejected. Force a hard reload after deploy or pin the build ID via generateBuildId.
fetch cache changed in Next 15 — code that relied on Next 14’s default force-cache for fetch no longer caches under Next 15. If your Server Action mutates data but the read still shows stale values, the read itself is probably no longer cached at all (so revalidatePath is a no-op) or, conversely, you added cache: 'force-cache' and now need revalidateTag.
Body size limit exceeded — large file uploads via Server Actions return a generic 500 with no helpful message until Next 14.1. Configure serverActions.bodySizeLimit in next.config.js if you upload more than 1MB.
For related Next.js issues, see Fix: Next.js API Route Not Working, Fix: Next.js Middleware Not Running, Fix: Next.js App Router Fetch Cache, and Fix: Next.js Hydration Failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: TanStack Start Not Working — Server Functions Failing, Routes Not Loading, or SSR Errors
How to fix TanStack Start issues — project setup, file-based routing, server functions with createServerFn, data loading, middleware, SSR hydration, and deployment configuration.
Fix: Waku Not Working — Server Components Not Rendering, Client Components Not Interactive, or Build Errors
How to fix Waku React framework issues — RSC setup, server and client component patterns, data fetching, routing, layout system, and deployment configuration.
Fix: Wasp Not Working — Build Failing, Auth Not Working, or Operations Returning Empty
How to fix Wasp full-stack framework issues — main.wasp configuration, queries and actions, authentication setup, Prisma integration, job scheduling, and deployment.
Fix: Clerk Not Working — Auth Not Loading, Middleware Blocking, or User Data Missing
How to fix Clerk authentication issues — ClerkProvider setup, middleware configuration, useUser and useAuth hooks, server-side auth, webhook handling, and organization features.