Skip to content

Fix: Next.js Server Action Not Working — Action Not Called or Returns Error

FixDevs · (Updated: )

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 error

Or 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 showing

Or 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 Components

Why 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() or revalidateTag() 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 / useActionState not 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 } in next.config.js and 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 useTransition and 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 experimental flag was removed. useFormState (from react-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 configurable serverActions.bodySizeLimit.
  • Next 15.0 (Oct 2024) — flipped caching defaults: fetch is no longer cached by default, route segments use dynamic rendering unless opted in, and the Router Cache no longer holds page segments after navigation. useFormState was renamed to useActionState and moved from react-dom to react (React 19). Async cookies/headers APIs require await calls.
  • Next 15.2 (Feb 2026 era backports) — improved server action allowed-origins enforcement. If you deploy behind a proxy with a different Host header, actions return 403 unless you configure serverActions.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 revalidated

Fix 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: useActionState was added in React 19. In Next.js 14 with React 18, use useFormState from react-dom instead (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 formData for sensitive operations like setting the authorId. 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-catchredirect() 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles