RizTech Academy logo
RizTech Academy
Next.js: Routing and RenderingLesson 5 of 630 min

Server actions, and forms without writing an API call

A server action is a function that runs on the server and can be called from the browser as if it were local. They remove most of the boilerplate around forms — and they are also a new place to be careless about security, so this lesson covers both.

The shape

// src/app/actions/subscribe.ts
"use server";

export async function subscribe(formData: FormData) {
  const email = String(formData.get("email") ?? "").trim();

  if (!email.includes("@")) {
    return { error: "Please enter a valid email address." };
  }

  await db.subscriber.create({ data: { email } });
  return { success: true };
}
import { subscribe } from "@/app/actions/subscribe";

export function SubscribeForm() {
  return (
    <form action={subscribe}>
      <input name="email" type="email" required />
      <button type="submit">Subscribe</button>
    </form>
  );
}

"use server" at the top of the file marks every export as a server action. action={subscribe} on the form wires it up.

What you did not write: an API route, a fetch, event.preventDefault(), JSON.stringify, or a client component. The form is a server component and it works with JavaScript disabled, because it is a real HTML form that posts.

Next.js creates an endpoint behind the scenes and replaces the form submission with a call to it when JavaScript is available.

Reading the form

Server actions receive FormData, so inputs are read by name:

const email = String(formData.get("email") ?? "").trim();
const quantity = Number(formData.get("quantity") ?? 0);

Every value is a string or null. The same conversion problem as everywhere else — Number("") is 0, and formData.get() on a missing field is null, not undefined.

For anything beyond two fields, validate with a schema rather than by hand. Zod is the usual choice:

const schema = z.object({
  email: z.string().email(),
  quantity: z.coerce.number().int().positive(),
});

const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
  return { error: "Please check the form and try again." };
}

Every action is a public endpoint

This is the part to take seriously.

A server action compiles to an HTTP endpoint that anyone can call. It is not protected by being imported into one form, and not protected by the form being hidden.

"use server";

export async function deleteProduct(id: string) {
  await db.product.delete({ where: { id } });    // anyone can call this
}

Hiding the delete button from non-admins does nothing. The action is reachable.

Every server action must check authentication and authorisation itself, as if it were a controller — because it is one:

"use server";

export async function deleteProduct(id: string) {
  const user = await getCurrentUser();
  if (!user) return { error: "Not signed in." };
  if (user.role !== "admin") return { error: "Not allowed." };

  await db.product.delete({ where: { id } });
  revalidateTag("products");
  return { success: true };
}

The rule from module 1, restated: the browser decides what to show, the server decides what is allowed. Server actions make the boundary feel invisible, which makes it easier to forget there is one.

Pending state

"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      aria-busy={pending}
      className={pending ? "pointer-events-none opacity-70" : ""}
    >
      {pending ? "Saving…" : children}
    </button>
  );
}

useFormStatus reads the status of the nearest parent form, which is why the button must be its own client component — a component cannot read its own form's status.

Note it is not disabled, for the reason in the forms lesson: a disabled submit button can cancel the submission that was meant to disable it.

Returning errors to the form

"use client";

import { useActionState } from "react";
import { subscribe } from "@/app/actions/subscribe";

export function SubscribeForm() {
  const [state, formAction] = useActionState(subscribe, null);

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      {state?.error && <p className="text-red-600">{state.error}</p>}
      {state?.success && <p className="text-emerald-600">Thanks!</p>}
      <SubmitButton>Subscribe</SubmitButton>
    </form>
  );
}

useActionState gives you the action's return value. The action signature gains a previous-state parameter:

export async function subscribe(previous: unknown, formData: FormData) { ... }

This does require a client component, which is the trade: a plain action={subscribe} works without JavaScript but cannot show inline errors.

Return errors rather than throwing them for anything the user can fix. Throwing hits error.tsx and loses the form.

Refreshing after a change

"use server";

export async function addToCart(productId: string, quantity: number) {
  const user = await getCurrentUser();
  if (!user) return { error: "Please sign in." };

  await db.cartItem.create({ data: { userId: user.id, productId, quantity } });

  revalidatePath("/cart");
  return { success: true };
}

Without revalidatePath or revalidateTag, the cart page keeps its cached version and the item appears to vanish. A mutation almost always needs a revalidate, and forgetting it is the most common server action bug.

Redirect when the user should move on:

import { redirect } from "next/navigation";

export async function checkout(formData: FormData) {
  const order = await createOrder(formData);
  redirect(`/orders/${order.id}`);
}

redirect() throws internally to unwind, so it must be called outside a try/catch — inside one, your catch swallows it and the redirect silently does not happen. That is a genuinely confusing bug worth knowing in advance.

Calling one from a button

Not everything is a form:

"use client";

export function DeleteButton({ id }: { id: string }) {
  return (
    <form action={async () => { await deleteProduct(id); }}>
      <button type="submit">Delete</button>
    </form>
  );
}

Still a form, which keeps the pending state and progressive enhancement. You can also call an action directly from an event handler inside a transition, but the form is simpler.

When to use an action versus the API

In this course you have both, so:

Server actions for form submissions from the Next.js app: a newsletter signup, an address form, an admin edit.

The NestJS API for anything another client needs — a mobile app, the shop owner's tooling, a partner — and for the substantial business logic: orders, stock, payments.

They are not competitors. An action can call your API.

Check your work

What "use server" does: marks every export in the file as a server action, which compiles to an HTTP endpoint.

Why every action needs its own auth check: the endpoint is public and reachable directly. Hiding the button that calls it protects nothing.

Why the button is not disabled during submission: a disabled submit button can cancel the submission that was meant to disable it.

Why useFormStatus needs its own component: it reads the status of the nearest parent form, so it cannot be called in the component that renders the form.

Why return errors rather than throw: throwing hits error.tsx and loses the form and everything typed into it.

The most common server action bug: forgetting revalidatePath or revalidateTag, so the page keeps its cached version and the change appears not to have happened.

Why redirect() must not be inside a try: it throws internally to unwind, so your catch swallows it and the redirect silently does not happen.

Practice

  1. Build a subscribe form using a server action with no client component. Turn JavaScript off in DevTools and confirm it still works.
  2. Log something in the action and note the log is in your terminal.
  3. Write an action that deletes something with no auth check. Call it with curl and confirm it works. Then add the check.
  4. Add a SubmitButton with useFormStatus. Slow the action down to see it.
  5. Return a validation error and show it with useActionState.
  6. Mutate data without revalidating, and watch a stale page. Add revalidatePath.
  7. Put redirect() inside a try/catch and watch it silently fail. Move it out.
  8. Convert a delete button into a form calling an action.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship