Skip to main content
ASoc
Tutorial

Next.js Server Actions: When You Need revalidatePath (and When You Don't)

Eight Server Actions in this codebase split cleanly on one rule: revalidate what stays, redirect past what doesn't. The one action that breaks the pattern on purpose.

The ASoc Team10 min read

A Next.js Server Action needs revalidatePath() only when it returns state for the same page to re-render in place — an action that redirects doesn't, because the redirect itself forces a fresh render of the destination. Get that backwards and you either revalidate a cache nobody reads or ship a stale dashboard after a real write. This storefront's eight Server Actions split cleanly on exactly that line.

The eight actions, sorted by what happens after they run

src/lib/actions/ holds eight Server Actions across seven files. Three call revalidatePath, three call redirect, and two do neither:

FileActionAfter success
redemption.tsredeemSlotrevalidatePath("/dashboard"), returns state
refund.tsrequestRefundrevalidatePath("/dashboard"), returns state
account.tsupdateProfile / updateEmailrevalidatePath("/dashboard") ×2, returns state
account.tsdeleteAccountredirect("/") — no revalidation
account.tsresendVerificationEmailredirect("/dashboard?verification=…") — no revalidation
auth.tssignOutredirect("/login") — no revalidation
auth.tssignInWithGoogleredirect(oauthUrl) — no revalidation
checkout.ts, contact.ts, newsletter.tsread-only or nothing cachedneither

That's not an inconsistent codebase — it's one rule applied eight times.

The rule: revalidate what stays, redirect past what doesn't

updateProfile is the clearest case. It's wired to useActionState, so a failed or successful submit re-renders the same settings page the user is already looking at — React doesn't re-fetch a Server Component tree on its own just because a mutation happened underneath it:

// src/lib/actions/account.ts
export async function updateProfile(
  _prev: AccountState,
  formData: FormData,
): Promise<AccountState> {
  // ...validate, then write via the admin client (profiles has no
  // authenticated write policy — see the RLS posture in supabase-vs-prisma)
  const { error } = await admin
    .from("profiles")
    .upsert({ id: user.id, display_name: displayName }, { onConflict: "id" });
  if (error) {
    console.error("account: updateProfile failed", error);
    return { ok: false, message: GENERIC_ERROR };
  }

  revalidatePath("/dashboard");
  revalidatePath("/dashboard/settings");
  return { ok: true, message: "Profile updated." };
}

Without those two calls, the write succeeds in Postgres but the dashboard's cached render still shows the old display name until the next hard navigation — a real bug this shape would produce, not a hypothetical. Two calls, not one, because the display name renders in both /dashboard's header and /dashboard/settings's form; revalidating only the page you're standing on misses the other one silently.

deleteAccount sits right next to it in the same file and calls neither:

export async function deleteAccount(
  _prev: AccountState,
  formData: FormData,
): Promise<AccountState> {
  // ...confirm phrase, delete the Supabase user, sign out this browser
  await supabase.auth.signOut();
  redirect("/");
}

There's nothing to revalidate — the destination is /, a static marketing page that never rendered anything scoped to this user in the first place. redirect() already forces Next.js to render / fresh; calling revalidatePath("/dashboard") first would be dead code invalidating a cache entry nobody's about to read, since the caller has just been signed out of that route entirely.

resendVerificationEmail looks closer to the revalidate cases — it redirects back to /dashboard, the same route updateProfile invalidates — and still skips it:

export async function resendVerificationEmail(): Promise<void> {
  // ...
  if (error) {
    console.error("account: resendVerificationEmail failed", error);
    redirect("/dashboard?verification=error");
  }
  redirect("/dashboard?verification=sent");
}

The difference is the query string. redirect() is a real navigation to a new URL, and Next.js renders whatever that URL points to from scratch — there's no stale cache to invalidate because there's no "staying in place" happening. updateProfile needs revalidatePath precisely because it does not navigate; resendVerificationEmail needs it even less than deleteAccount does, because it's landing on a route it might have just mutated, but the mutation (sending an email) doesn't change anything /dashboard renders. Nothing to invalidate, full stop.

The action that returns success and still doesn't redirect

signInWithPassword breaks the two-bucket pattern above in a way worth calling out on its own, because it's the one place this codebase deliberately leaves navigation to the client:

// src/lib/actions/auth.ts
export async function signInWithPassword(
  _prev: AuthState,
  formData: FormData,
): Promise<AuthState> {
  // ...validate, then
  const { error } = await supabase.auth.signInWithPassword({ email, password });
  if (error) {
    return { ok: false, message: "Incorrect email or password." };
  }
  return { ok: true, message: "Signed in — redirecting…" };
}

No redirect() call, despite the message saying otherwise. The navigation happens in the client component wrapping it, AuthCard.tsx:

// src/components/molecules/AuthCard.tsx
const [state, formAction, pending] = useActionState<AuthState, FormData>(action, null);
const router = useRouter();

useEffect(() => {
  if (state?.ok && redirectTo) {
    router.push(redirectTo);
  }
}, [state, redirectTo, router]);

useActionState's pending flag drives the submit button's "Please wait…" label, and the success message is meant to be visible for a beat before the page actually changes — a server-issued redirect() thrown from inside the action would cut that transition off mid-flight instead of letting the component finish rendering its own "Signed in — redirecting…" state first. Every other action in this codebase either mutates a page and stays (revalidate) or leaves immediately (redirect); signInWithPassword is the one action doing both — leaving, but on the client's terms.

A working example, written for this article

Nothing in the catalog needs a like button, so here's a small self-contained Server Action showing the same pattern in miniature — mutate, decide, revalidate or don't:

"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function toggleFeatured(prevState: unknown, formData: FormData) {
  const id = String(formData.get("postId"));
  const shouldRedirect = formData.get("redirectAfter") === "true";

  await db.posts.update(id, { featured: (v) => !v });

  if (shouldRedirect) {
    redirect(`/posts/${id}`); // navigating away — nothing to revalidate here
  }

  revalidatePath("/posts"); // staying on the index — tell it its cache is stale
  return { ok: true };
}

The if isn't contrived — it's the same branch every action above resolves once, at write time, based on whether the caller stays or leaves.

Troubleshooting

SymptomCauseFix
Server Actions example ("not working") — UI shows stale data after a successful submitNo revalidatePath/revalidateTag call, and the component isn't re-fetching on its ownCall revalidatePath for every route that renders the mutated data — plural, if more than one does
redirect() call has no visible effect, or throws in a try/catchredirect() works by throwing a special NEXT_REDIRECT error internally; a surrounding try/catch swallows it before Next.js can act on itCall redirect() outside any try/catch that wraps it, or re-throw digest errors explicitly
Adding revalidatePath "just in case" doesn't fix stale dataRevalidated the wrong path — a shared display name lives on two routes, not oneList every route that actually renders the changed data, per field, the way updateProfile revalidates both /dashboard and /dashboard/settings
A Server Action can't be found / import error at build timeFunction isn't async, or the file/function is missing "use server"Every exported function in a "use server" file must be async; mark the function or the file, not neither
Extra console.error noise, generic message reaches the user anywayIntentional — see GENERIC_ERROR aboveLog the real error server-side, return one fixed string; this is deliberate for account-enumeration safety, not a bug

FAQ

How do I add a Server Action to a Next.js app? Write an async function, mark it (or its file) with "use server" at the top, and pass it to a <form action={myAction}> or call it from a Client Component. No API route, no fetch call, no manual JSON parsing — Next.js generates the wiring.

What are Next.js Server Actions best practices for cache invalidation? Call revalidatePath (or revalidateTag) for every route that reads the data you just changed, not just the one you're currently on — and skip it entirely when the action redirects, since the destination renders fresh regardless.

Why does my Server Action seem to succeed but the page doesn't update? Almost always a missing revalidatePath/revalidateTag call, or one pointed at the wrong route. The Server Actions vs. API Routes post covers the adjacent "is this even the right primitive" question if the mutation itself is the concern rather than the refresh.

Do I need useActionState for every Server Action? No — only when the caller needs pending/error UI in the same component. signOut and signInWithGoogle above are bound directly to <form action={...}> with no wrapper at all, because they always redirect and never render a result in place.

Templates in this post

ASoc Iris is a computer-vision studio site with case studies and a pricing table — the kind of marketing page whose demo-request form is exactly the "mutate, stay in place" shape updateProfile demonstrates above. ASoc Ledger is a finance-app marketing site with live dashboard previews and cashflow tracking, where an in-page settings or subscription update would follow the same revalidate-not-redirect rule. ASoc Magnet is a lead-generation SaaS site whose capture forms are the redirect-and-leave case — a thank-you page navigation with nothing left behind to invalidate.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates. For the broader question of Server Action vs. Route Handler, see Server Actions vs. API Routes; for the auth actions referenced above in full, Auth in React.

Keep reading

Tutorial11 min read

Shopping Cart in Next.js 16: Where Cart State Should Live

A cookie, a database row, or React Context? The three cart architectures compared, the add-to-cart Server Action, and the badge that quietly breaks static rendering.

Read more
Tutorial11 min read

Storefront Search Without a Search Service

111 products, no search index. The predicate, the rule that stops results looking broken, and the bundle trade we took on one page only.

Read more