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.
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:
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);
}
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.
'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.
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.
'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:
'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 };
}
'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
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.
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();
}
'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.
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.
'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
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:
- Add an
app/api/products/route.tsRoute Handler withGETandPOST, and confirm it responds correctly with a tool like Postman orcurl— independent of any page rendering it. - Add a
'use server'actions file withaddProduct,updateProductanddeleteProduct, and wireaddProductto a plain<form action={...}>on a new "Add Product" page. - Convert the add-product form to use
useActionState, returning a validation error when the name is empty, and display it in the UI. - Add a
DeleteButtonClient Component (Section 3) to each product card, usinguseTransitionfor its pending state. - Tag your
getProductsread with"use cache"+cacheTag('products')(Section 5), and callupdateTag('products')in all three actions — confirm, by adding a product then navigating back to/productswithout a hard refresh, that the new item appears immediately.
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:
- Add a
favorited: booleanfield to your product data and atoggleFavoriteServer Action that flips it. - Build the
FavoriteButtonfrom Section 6 and add it to every product card, passing each card's currentfavoritedvalue asinitialFavorited. - Add an artificial 800ms delay to
toggleFavoriteand confirm the star still flips instantly on click, well before the delay completes. - Make
toggleFavoritethrow 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 inFavoriteButton.
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 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?
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>?
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 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.