Wire up a button that adds something to a cart. The shape of the work never changes. Design a URL. Write a serialiser for the request. Write another for the response. Write a client-side function that calls it. Keep all four in sync forever. For one button.
Next.js made that go away, and made it look like something only React's compiler could do. It isn't. It's a pattern any backend can have, including one that's never heard of a Server Component. Here's what a server action actually is, how to build the same shape in Django, and how to call it from React once it exists.
What a server action actually is
// app/cart/actions.ts
"use server";
export async function addToCart(productId: string, quantity: number) {
const session = await getSession();
await db.cartItem.create({ data: { userId: session.userId, productId, quantity } });
revalidatePath("/cart");
}
<button onClick={() => addToCart(product.id, 1)}>Add to cart</button>
No URL you wrote. No route handler. No client-side fetch. "use server" marks the function, and at build time the compiler swaps it out for a reference, an action ID plus a dispatcher, that posts back to the very page that called it. A JavaScript-driven call sends that ID in a next-action header; the server looks it up in a module map and runs the matching function. A plain HTML form can't set a header, so React falls back to a hidden field instead, literally named $ACTION_ID_<id>. Same lookup, two different places to find the key.
Put that same function on a form inside a Server Component instead of a button:
// app/cart/page.tsx (Server Component)
import { addToCart } from "./actions";
<form action={addToCart}>
<input type="hidden" name="productId" value={product.id} />
<button type="submit">Add to cart</button>
</form>
and the browser submits it as a genuine POST even with JavaScript fully disabled. That's Next.js's progressive enhancement. The guarantee is narrower than it sounds, though: move the same form into a Client Component, and a submission before hydration doesn't bypass JavaScript, it queues until React finishes hydrating. The button-and-onClick version above never worked without JavaScript. Only a form rendered by a Server Component does.
The trick has nothing to do with React
Peel back the compiler and the shape underneath is plain: a name, mapped to a function, behind one endpoint. Something validates the input, runs the function, and sends back whatever changed. The compiler is what makes that invisible. It's not what makes it possible.
HTML has had half of this since before JavaScript existed. A <form method="post" action="/cart"> has always posted back to a URL and re-rendered the result. Server actions are that idea, generalised, with the boilerplate compiled away. Remove the compiler and you can still build the idea by hand.
Building it in Django
A Next.js action posts to the page it was called from, not to some separate API route, and that detail matters. It's what lets a validation error re-render inline, with the rest of the page still there. So the Django version needs the same shape: a name, mapped to a function, registered once. It has to be reachable the same two ways Next.js does it: its own header for JavaScript, a hidden field for a plain form.
# actions.py
ACTIONS = {}
ACTION_FORMS = {}
def action(name, form):
def register(fn):
ACTIONS[name] = fn
ACTION_FORMS[name] = form
return fn
return register
@action("cart.add", AddToCartForm)
def add_to_cart(request, product_id, quantity):
CartItem.objects.create(user=request.user, product_id=product_id, quantity=quantity)
return {"cart": serialize_cart(request.user)}
Not a separate endpoint, though. A small function any page view can call first, before it does its own rendering:
# actions.py (continued)
def dispatch_action(request):
"""Call this at the top of any view. Returns a response if the request
was an action call, or None if the view should just render as normal."""
name = request.headers.get("X-Action") or request.POST.get("_action")
if request.method != "POST" or name is None:
return None
fn, form_cls = ACTIONS.get(name), ACTION_FORMS.get(name)
if fn is None:
return HttpResponseForbidden()
payload = json.loads(request.body) if request.headers.get("X-Action") else request.POST
form = form_cls(payload)
if not form.is_valid():
if request.headers.get("X-Action"):
return JsonResponse({"ok": False, "errors": form.errors}, status=400)
request.action_errors = form.errors # the view's own render() picks this up
return None
result = fn(request, **form.cleaned_data)
if request.headers.get("X-Action"):
return JsonResponse({"ok": True, **result})
return redirect(f"{request.path}?done={name}")
# views.py
@csrf_protect
def product_detail(request, slug):
if (response := dispatch_action(request)) is not None:
return response
product = get_object_or_404(Product, slug=slug)
return render(request, "product_detail.html", {
"product": product,
"errors": getattr(request, "action_errors", None),
})
Two callers, one function, and the page's own view stays in charge throughout. There's no script running to receive JSON on a plain form submit, so dispatch_action hands control back to the exact same view that was going to render the page anyway, form errors and all. Same branch Next.js's compiler generates for you. Here it's a function you can step through.
Validation reuses Django's own form class, on purpose. It's the same instinct as validate at the boundaries: trust nothing that crosses a boundary, and this endpoint is a boundary regardless of which language sits behind it.
Calling it from React
function useAction(name: string) {
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function call(data: Record<string, unknown>) {
setPending(true);
setError(null);
try {
const res = await fetch(window.location.pathname, {
method: "POST",
headers: { "X-Action": name, "X-CSRFToken": getCsrfToken(), "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const json = await res.json();
if (!json.ok) {
const firstError = Object.values(json.errors ?? {})[0] as string | undefined;
throw new Error(firstError ?? "Action failed");
}
return json;
} catch (e) {
setError((e as Error).message);
throw e;
} finally {
setPending(false);
}
}
return { call, pending, error };
}
function AddToCartButton({ productId }: { productId: string }) {
const { call, pending } = useAction("cart.add");
return (
<button disabled={pending} onClick={() => call({ product_id: productId, quantity: 1 })}>
{pending ? "Adding…" : "Add to cart"}
</button>
);
}
The component knows one thing: a name. It doesn't know the URL, because there's only ever one. And on a page where this button hasn't hydrated yet, or never will, the same action still has a door in:
<form method="post" action="{{ request.path }}">
{% csrf_token %}
<input type="hidden" name="_action" value="cart.add">
<input type="hidden" name="product_id" value="{{ product.id }}">
<button type="submit">Add to cart</button>
</form>
Same name, same view, same validation. Neither caller knows the other exists.
What you actually gave up
Every read that goes through this endpoint becomes a POST, and a POST is never cacheable by a browser or a CDN. Keep reads as ordinary GETs; route only mutations through actions. There's no public API on the side either: a mobile client calling cart.add needs its own endpoint wrapping the same function. Authorisation has to live inside the function itself, not in a route, because any registered action can be posted to from anywhere your CSRF token is valid. And action names are strings. A typo in "cart.ad" fails at runtime, not compile time, unless you generate a type from the registry and let the compiler catch it for you.
The part Next.js gets too much credit for
None of this required a compiler. It required writing down, by hand, what a <form> tag already knew: post back to where you are, validate what arrives, send back what changed. Next.js packaged that well enough that a generation of developers assumed you needed React's compiler to get it. You don't. You need one endpoint, one registry, and the discipline to keep validation at the boundary regardless of which caller shows up.