Week 20: Next.js — Route Handlers & Server Actions

Weeks 18–19 covered reading data — Server Components fetching and rendering it. This week covers writing it: exposing a real HTTP endpoint with a Route Handler, and the more idiomatic Next.js alternative for mutations, Server Actions — functions that run on the server but are called directly from a form or a Client Component, no separate API layer required.

Module 11 of 17 Week 20 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Build a Route Handler that behaves like a real REST endpoint
  • Write and call a Server Action from both a plain form and a Client Component
  • Revalidate cached data after a mutation so the UI reflects the write

1. Route Handlers

A file named route.ts inside any app/ folder replaces page.tsx for that segment and turns it into an HTTP endpoint instead of a page — the App Router's equivalent of an Express route from the Node/Express course, colocated with the rest of your app instead of living in a separate backend.

app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function GET() {
  const products = await db.product.findMany();
  return NextResponse.json(products);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const product = await db.product.create({ data: body });
  return NextResponse.json(product, { status: 201 });
}

Each exported function name — GET, POST, PUT, DELETE — maps to that HTTP method, exactly like Week 18's [productId] dynamic segment maps a folder name to a URL param. A dynamic Route Handler reads params the same way a dynamic page does:

app/api/products/[productId]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ productId: string }> },
) {
  const { productId } = await params;
  const product = await db.product.findUnique({ where: { id: productId } });
  if (!product) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 });
  }
  return NextResponse.json(product);
}
When a Route Handler is the right tool

Reach for a Route Handler when you need a real, externally-callable HTTP endpoint — a webhook receiver, an endpoint a mobile app or third party also calls, or a response type other than HTML (a file download, an RSS feed). For a mutation only your own app's forms and buttons ever trigger, Section 2's Server Actions are almost always less code.

2. Server Actions & 'use server'

A Server Action is an ordinary async function marked with a 'use server' directive — Week 18's 'use client', in reverse. Next.js compiles it into a callable network endpoint automatically; you write and call it like a normal function, with no fetch, no URL, and no Route Handler to define.

app/products/actions.ts
'use server'; // every exported function in this file becomes a Server Action

import { revalidatePath } from 'next/cache';

export async function addProduct(formData: FormData) {
  const name = formData.get('name') as string;
  const price = Number(formData.get('price'));

  await db.product.create({ data: { name, price } });
  revalidatePath('/products'); // see Section 5
}

A Server Action can be wired directly to a plain HTML <form>'s action prop — no onSubmit handler, no client-side state, and it still works with JavaScript disabled, because it's a genuine form submission under the hood.

app/products/new/page.tsx — a Server Component
import { addProduct } from '../actions';

export default function NewProductPage() {
  return (
    <form action={addProduct}>
      <input name="name" required />
      <input name="price" type="number" required />
      <button type="submit">Add product</button>
    </form>
  );
}

This is the App Router's replacement for Week 5's controlled-input pattern in the cases where the form's only job is "submit this to the server" — no useState tracking every keystroke, because the browser's native form submission carries the FormData to the action directly.

3. Calling Actions from Client Components

A Server Action isn't limited to a plain form — a Client Component can import and call one directly from an event handler, which is what most real "favorite this", "delete this row", or "like this post" buttons actually do.

app/products/DeleteButton.tsx
'use client';

import { useTransition } from 'react';
import { deleteProduct } from './actions';

export function DeleteButton({ productId }: { productId: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      disabled={isPending}
      onClick={() => startTransition(() => deleteProduct(productId))}
    >
      {isPending ? 'Deleting…' : 'Delete'}
    </button>
  );
}

useTransition marks the action call as a non-urgent update — the UI stays responsive while the request is in flight, and isPending gives you the loading state directly, without Week 9's manual mutation-state bookkeeping. Under the hood, calling a Server Action from the browser is still a network request to the endpoint Next.js generated for it — it just never shows up as a URL you'd write yourself.

4. useActionState & useFormStatus

For a plain <form action={...}>, two hooks give you the pending state and any returned result without reaching for useTransition by hand. useActionState wraps the action and tracks its last return value — useful for validation errors:

app/products/actions.ts — returning validation state
'use server';

export async function addProduct(prevState: unknown, formData: FormData) {
  const name = formData.get('name') as string;
  if (!name || name.length < 2) {
    return { error: 'Name must be at least 2 characters' };
  }
  await db.product.create({ data: { name } });
  revalidatePath('/products');
  return { error: null };
}
app/products/new/AddProductForm.tsx
'use client';

import { useActionState } from 'react';
import { addProduct } from '../actions';

export function AddProductForm() {
  const [state, formAction] = useActionState(addProduct, { error: null });

  return (
    <form action={formAction}>
      <input name="name" />
      {state.error && <p className="error">{state.error}</p>}
      <SubmitButton />
    </form>
  );
}

function SubmitButton() {
  const { pending } = useFormStatus(); // must be a child of the 
, not the form itself return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>; }

useFormStatus only works inside a component nested under the <form> it's reporting on — it reads pending state from the nearest parent form's submission, which is why SubmitButton is split into its own component rather than called inline in AddProductForm itself.

5. Revalidating After a Mutation

Week 21 covers Next.js 16's Cache Components model in full — the "use cache" directive and tagging cached data with cacheTag. For now, the part that matters here: after addProduct writes to the database, any cached rendering of the products list is now stale, and something needs to tell Next.js to throw it away.

app/products/queries.ts — a cached read, tagged
import { cacheTag } from 'next/cache';

export async function getProducts() {
  'use cache';
  cacheTag('products'); // labels this cached result so it can be targeted later

  return db.product.findMany();
}
app/products/actions.ts — revalidating after the write
'use server';

import { updateTag } from 'next/cache';

export async function addProduct(formData: FormData) {
  const name = formData.get('name') as string;
  const price = Number(formData.get('price'));

  await db.product.create({ data: { name, price } });
  updateTag('products'); // expires the 'products' cache immediately, in this same action
}

updateTag is the revalidation call written specifically for Server Actions: it expires the tagged cache entry immediately, so the very next read — including anything this same action does afterward, like a redirect to the products page — sees the fresh data, not a stale cached copy. Its sibling, revalidateTag, marks an entry stale rather than expiring it outright: the next visitor gets the still-cached page instantly while Next.js rebuilds it in the background — the right choice from a Route Handler or a webhook, where nothing is waiting to immediately read its own write the way a Server Action's caller is. revalidatePath('/products') still exists too, for the simpler case of invalidating everything cached for a specific route rather than a specific tag.

This is the App Router's answer to Week 9's queryClient.invalidateQueries — same underlying idea, cache correctness after a write, expressed at the framework level instead of by a client-side query library, because the cache being invalidated here can live on the server, not just in the browser.

Forgetting to revalidate is the most common Server Action bug

A Server Action that writes to the database but never calls updateTag/revalidateTag/revalidatePath will appear to silently "not work" — the write succeeded, but every page still shows the old cached data until the cache's lifetime expires or a full reload happens to bypass it.

6. useOptimistic + Server Actions

Week 9 introduced useOptimistic and promised its most natural home was here: a Server Action call, unlike a TanStack Query mutation, has no client-side cache of its own to optimistically update — useOptimistic is exactly the tool built for pairing with it directly.

FavoriteButton.tsx
'use client';

import { useOptimistic, useTransition } from 'react';
import { toggleFavorite } from './actions';

export function FavoriteButton({ productId, initialFavorited }: {
  productId: string;
  initialFavorited: boolean;
}) {
  const [isPending, startTransition] = useTransition();
  const [optimisticFavorited, setOptimisticFavorited] = useOptimistic(initialFavorited);

  function handleClick() {
    startTransition(async () => {
      setOptimisticFavorited(!optimisticFavorited); // flips instantly
      await toggleFavorite(productId); // real Server Action call, in the background
    });
  }

  return (
    <button onClick={handleClick} disabled={isPending}>
      {optimisticFavorited ? '★ Favorited' : '☆ Favorite'}
    </button>
  );
}

The star flips the instant the button is clicked, before toggleFavorite has even reached the server. useOptimistic's update must run inside a transition — startTransition, exactly like Section 3's DeleteButton — so that if the action ultimately throws, optimisticFavorited automatically reverts to initialFavorited with no onError code of your own, the same revert-on-settle behavior Week 9 covered for a purely client-side case.

initialFavorited comes from the server, on every fresh load

Because this is a Client Component rendered inside a Server Component's page, initialFavorited reflects whatever updateTag/revalidatePath (Section 5) already made sure is current, on the next full page load. useOptimistic only smooths over the brief window during one specific interaction — it isn't a substitute for revalidating the underlying cache correctly.

7. Hands-on Exercise

Hands-on

Add full CRUD to Week 18's product catalog, with an optimistic favorite toggle

Give the read-only catalog from Week 18 the ability to create, update and delete — then make one interaction feel instant.

Part 1 — CRUD:

  1. Add an app/api/products/route.ts Route Handler with GET and POST, and confirm it responds correctly with a tool like Postman or curl — independent of any page rendering it.
  2. Add a 'use server' actions file with addProduct, updateProduct and deleteProduct, and wire addProduct to a plain <form action={...}> on a new "Add Product" page.
  3. Convert the add-product form to use useActionState, returning a validation error when the name is empty, and display it in the UI.
  4. Add a DeleteButton Client Component (Section 3) to each product card, using useTransition for its pending state.
  5. Tag your getProducts read with "use cache" + cacheTag('products') (Section 5), and call updateTag('products') in all three actions — confirm, by adding a product then navigating back to /products without a hard refresh, that the new item appears immediately.
Hint

If a mutation "worked" in the database but the UI still shows stale data after navigating back, that's almost always a missing or misplaced updateTag/revalidatePath call — check Section 5 before suspecting the mutation itself.

Part 2 — Optimistic favoriting:

  1. Add a favorited: boolean field to your product data and a toggleFavorite Server Action that flips it.
  2. Build the FavoriteButton from Section 6 and add it to every product card, passing each card's current favorited value as initialFavorited.
  3. Add an artificial 800ms delay to toggleFavorite and confirm the star still flips instantly on click, well before the delay completes.
  4. Make toggleFavorite throw for one specific product ID (to simulate a failure), click that product's favorite button, and confirm the star reverts back to its original state once the action rejects — with no manual rollback code in FavoriteButton.
Hint

If the optimistic star doesn't revert on failure, double-check the setOptimisticFavorited call and the await toggleFavorite(...) call are both inside the same startTransition callback — useOptimistic only knows to revert once the transition it was updated inside of finishes, success or failure.

8. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

When should you reach for a Route Handler instead of a Server Action?

When you need a genuine, externally-callable HTTP endpoint — a webhook receiver, something a mobile app or third party calls directly, or a non-HTML response like a file or feed. For a mutation only your own app's own forms and buttons trigger, a Server Action is almost always less code and avoids maintaining a separate endpoint URL.

Q2

Why does addProduct's form still work correctly even with JavaScript disabled in the browser?

Passing a Server Action directly to a <form>'s action prop uses the browser's native form submission mechanism, not a client-side JavaScript fetch — Next.js progressively enhances that same submission with JavaScript when it's available, but the underlying HTTP form post still reaches the server and runs the action without it.

Q3

Why must SubmitButton be a separate component from AddProductForm, rather than calling useFormStatus directly inside the component that renders the <form>?

useFormStatus reads its pending state from the nearest ancestor <form>, so it only reports correctly when called from a component nested inside that form — calling it in the same component that renders the <form> tag itself doesn't have an ancestor form to read from.

Q4

A Server Action successfully writes a new row to the database, but the products page still shows the old list after navigating back to it. What's the most likely cause?

A missing (or incorrectly-targeted) revalidatePath/revalidateTag call. The write succeeded, but the cached render of that route was never invalidated, so Next.js keeps serving the stale cached HTML until the cache naturally expires or the missing revalidation call is added.