Skip to main content
العربية
Back to Blog
Technology
Updated
3 min read

A Deep Dive into Next.js Server Actions

Learn how Next.js Server Actions simplify data mutations by removing the need for API routes and managing client-server communication seamlessly.

اقرأ هذه المقالة بالعربية

A Deep Dive into Next.js Server Actions

The problem with traditional forms

For years, submitting a form meant writing an API route, wiring up fetch, managing loading and error state by hand, and then discovering CORS. The form lived in one file, the endpoint in another, and the only thing connecting them was a URL string that no compiler ever checked.

Server Actions collapse that. You write one function, mark it as server-side, and hand it straight to a <form>. Next.js takes care of the network in between.

What a Server Action actually is

A Server Action is a function that only ever runs on the server, even though you reference it from client code. The "use server" directive is what marks it:

// src/actions/contact.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/db/client";

const Schema = z.object({
  email: z.string().email(),
  message: z.string().min(10).max(5000),
});

export async function submitContact(formData: FormData) {
  const parsed = Schema.safeParse({
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!parsed.success) {
    return { ok: false, error: "Please check the form and try again." };
  }

  await prisma.contactSubmission.create({ data: parsed.data });
  revalidatePath("/admin/messages");

  return { ok: true, error: null };
}

The form itself needs no state, no handler, and no useEffect:

import { submitContact } from "@/actions/contact";

export default function ContactPage() {
  return (
    <form action={submitContact}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}

That is the whole round trip. No API route, no fetch, no JSON serialisation you wrote yourself.

It works before JavaScript loads

This is the part that gets undersold. Passing a Server Action to action produces a form that genuinely submits without client JavaScript — Next.js renders a real POST target and progressively enhances it once hydration completes.

On a fast laptop that is a technical curiosity. On a phone on a weak mobile connection, hydration can be seconds behind first paint, and every one of those seconds is a window where a normal React form silently swallows clicks. A form that posts anyway is not a purist's detail; it is the difference between receiving the enquiry and not.

Reading the result

The signature changes when you want to show the outcome. useActionState passes the previous state as the first argument, so the action becomes (prevState, formData):

"use client";

import { useActionState } from "react";
import { submitContact } from "@/actions/contact";

const initial = { ok: false, error: null };

export function ContactForm() {
  const [state, formAction, pending] = useActionState(submitContact, initial);

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={pending}>
        {pending ? "Sending…" : "Send"}
      </button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

Mixing the two signatures up is the most common Server Actions bug I see: an action written for action={fn} and then passed to useActionState receives FormData in the second parameter, and every field silently reads as null.

Every action is a public endpoint

This one matters more than the ergonomics, and it is the thing tutorials skip.

A "use server" export is compiled into a callable HTTP endpoint. Anyone can invoke it with a crafted request. It does not matter that your UI only renders the button for logged-in admins — the button is not the security boundary, the function is.

So every action that mutates anything checks authorisation itself, first:

"use server";

export async function deleteProject(id: string) {
  const adminId = await getAdminId();
  if (!adminId) throw new Error("Unauthorised");

  await prisma.project.delete({ where: { id } });
  revalidatePath("/admin/projects");
}

The same applies to validation. required on an input is a hint to a browser, not a constraint on a request. Parse everything server-side — the safeParse above is not ceremony, it is the only validation that actually holds.

When not to use one

Server Actions are for mutations. They run sequentially and they are not a general-purpose data-fetching layer:

  • Reading data? Fetch it in a Server Component. That is already server-side and needs no action at all.
  • Something a third party calls? That is a Route Handler with its own authentication, not an action.
  • Long-running work? An action holds the request open. Queue it instead.

What this means if you are paying for a website

Nothing above requires you to care about React. What it buys you is concrete: fewer moving parts to break, forms that still work while the page is loading, validation that cannot be bypassed by turning off JavaScript, and an admin panel built from the same code as the public site rather than bolted on beside it.

That is the stack I build every client site on, and it is why the Next.js work I do across North Lebanon starts from the App Router rather than from a template. If you are weighing up a rebuild, the guide to choosing a developer on the Minieh page lists the questions worth asking whoever you end up hiring.