Course outline · 0% complete

0/29 lessons0%

Course overview →

After the write: revalidate and pending states

lesson 6-2 · ~11 min · 20/29

The stale-list bug

Here is a classic trap. The user publishes a post, lands back on /blog, and the new post is missing. The cause comes straight from Unit 4: the blog page's data was cached. The database changed but the cache never heard about it.

Server actions fix this by telling Next.js what they touched:

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

export async function createPost(formData) {
  await db.posts.insert({ title: formData.get("title") });
  revalidatePath("/blog");
}

revalidatePath("/blog") throws away the cached data for that route, so the next render fetches fresh data and the new post appears. Mutation plus revalidation is the complete pattern: write, then invalidate what you wrote to.

1. insert row(database)2. revalidatePath(drop the cache)3. redirect(navigate)Wrong order: redirect throws, so step 2 never runs.insert rowredirectrevalidate (dead)The list page keeps serving a stale cached copy.
The mutation loop in order: the action writes to the database, revalidatePath invalidates the cached route, and only then does redirect send the user to a page that will render fresh data.

Diagnosing a comment that will not disappear

A delete-comment server action removes a row from the database, but the comments page keeps showing the deleted comment until the cache expires on its own. The missing line is a revalidation call, revalidatePath("/comments"), or a tag-based equivalent.

The database changed and the cached page data did not, because nothing connected the two. Calling revalidatePath for the affected route, or revalidateTag for tagged data, invalidates the stale copy so the next render reflects the deletion.

Deletions make this bug especially visible. With a create, users see something missing and might blame themselves for a failed submit. With a delete, they see something they explicitly removed still sitting on the page, which reads as a broken app.

Sending the user somewhere new

Revalidation refreshes data, but often the user should move too. After publishing, authors expect to land on their new post rather than stay on a blank form. Server actions can end with a navigation by calling redirect:

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

export async function createPost(formData) {
  const post = await db.posts.insert({ title: formData.get("title") });
  revalidatePath("/blog");
  redirect(`/blog/${post.slug}`);
}

Decode it:

  • redirect() throws internally to stop the action, so it must come last. Code after it never runs, and wrapping it in a try/catch out of habit will swallow the navigation.
  • Order matters: revalidate first, then redirect, so the page the user lands on, and the list they go back to, are both already fresh.
  • This is the same "POST, then redirect" pattern you met in Backend with Node.js. It prevents a browser refresh from resubmitting the form.

Getting the order wrong

Picture a createPost action that inserts the row, calls `redirect(/blog/${slug}), and then calls revalidatePath("/blog") on the next line. The revalidation **never runs**, so /blog` can keep serving the stale cached list.

redirect() works by throwing a special control-flow error that ends the action immediately. Anything written after it is dead code, and unlike a normal bug it fails silently: the navigation succeeds, the user lands on their new post, and only the list page is quietly wrong.

The fix is to put cleanup work before the redirect. Write, invalidate, then navigate. That order guarantees the user lands on fresh data and that going back shows a fresh list too.

Showing 'Saving…'

Users need feedback while the action runs. React's useActionState hook (a client-component tool, as you'd expect from the React course) returns a pending flag:

"use client";
import { useActionState } from "react";
import { createPost } from "../actions";

export function PublishForm() {
  const [state, action, pending] = useActionState(createPost, null);
  return (
    <form action={action}>
      <input name="title" />
      <button disabled={pending}>{pending ? "Saving…" : "Publish"}</button>
    </form>
  );
}

While the server action is in flight, pending is true, so the button disables itself and changes label. Same hooks philosophy as always: state in, UI out.

The complete mutation pattern

The full recipe in Next.js has two steps that must both happen. Write to the data source, then call revalidatePath, or revalidateTag when you tag your fetches, so cached pages that display that data are refreshed.

A mutation changes the database, not the cache. The two are separate systems, and nothing informs one about the other automatically. Unless the action explicitly invalidates the affected route or tag, users keep seeing the stale cached copy until it naturally expires.

Why it works out that way

  • Both functions come from next/cache, which is the module that owns cache lifetime in Next.js.
  • The call belongs right after the write, as in the db.posts.insert example above, and before any redirect.
  • Choose between them by scope. revalidatePath targets a route you can name, while revalidateTag targets every fetch you labelled with a tag, which is handier when the same data feeds several pages.