Hirefullstack – Software Engineering & IT-Beratung aus Berlin
← Back to overview
Core 15 min read

Server actions & forms

Writing without an API in between

In one sentence

A server action is a function that runs on the server and that you call straight from a form. No fetch, no route, no JSON.

Reading was easy: await in the component. Writing always needed an API route, a fetch, JSON back and forth. A Server actionA function that runs on the server and that you can call straight from a form or a click – with no API in between. turns that into a function call.

app/contact/page.tsx
export default function Contact() {
  async function submit(formData: FormData) {
    "use server";                     // runs on the server

    const mail = formData.get("mail") as string;
    await db.enquiry.create({ data: { mail } });
  }

  return (
    <form action={submit}>
      <input name="mail" type="email" required />
      <button>Send</button>
    </form>
  );
}
No onSubmit, no preventDefault, no fetch. The form calls the function directly.
Good to know It even works without JavaScript

Because it is a real <form> with an action, the browser can submit it the classic way if it has to. Someone on a slow connection, or before the script has loaded, can still use the form.

Put another way

Writing used to be like posting a letter to your own company: address it, send it, open it again in the mailroom. A server action is the call to the next office – the function is invoked directly and Next.js builds the postal route invisibly around it.

Where actions live

app/actions.ts
"use server";        // at the top: EVERY export is a server action

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  if (!title?.trim()) return;

  const post = await db.post.create({ data: { title } });

  revalidatePath("/blog");
  redirect("/blog/" + post.slug);
}
In a file of their own, actions can also be imported from client components.
Careful A server action is a public endpoint

Next.js creates an endpoint for every action. Anyone can call it – with any values. So check inside the action whether the person is signed in and allowed to do this. Hiding the button in the interface protects nothing.

Feedback for the user

For validation errors and loading states there is useActionState – a React hook giving you the action's last return value, the action itself and a pending flag.

app/contact/Form.tsx
"use client";

import { useActionState } from "react";
import { sendEnquiry } from "../actions";

export function Form() {
  const [state, action, pending] = useActionState(sendEnquiry, {
    error: null,
  });

  return (
    <form action={action}>
      <input name="mail" type="email" />
      <button disabled={pending}>{pending ? "Sending …" : "Send"}</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}
app/actions.ts
"use server";

type State = { error: string | null; success?: boolean };

export async function sendEnquiry(
  previous: State,
  formData: FormData,
): Promise<State> {
  const mail = String(formData.get("mail") ?? "");
  if (!mail.includes("@")) return { error: "Please enter a valid address" };

  await db.enquiry.create({ data: { mail } });
  return { error: null, success: true };
}
With useActionState the action receives the previous state as its first argument.
Tip The submit button can disable itself

useFormStatus() reads the surrounding form's state. That lets you build a <SubmitButton> that works everywhere without threading pending through. Important: the hook has to live inside the <form>, not in the same component.

Not only forms

button.tsx
"use client";
import { useTransition } from "react";
import { favourite } from "../actions";

export function Heart({ id }: { id: string }) {
  const [pending, start] = useTransition();

  return (
    <button onClick={() => start(() => favourite(id))} disabled={pending}>

    </button>
  );
}
From a click you wrap the call in startTransition – otherwise the interface blocks.
Reach for it when …
  • +

    Forms of every kind – create, edit, delete

  • +

    Single actions like favouriting or subscribing

  • +

    Anything you want to revalidate afterwards anyway

Skip it when …
  • Pure reading – fetch that straight in the server component

  • As a general API for third-party systems – that is what route handlers are for

  • Without checking sign-in and permissions

In more depth: instant feedback with useOptimistic optional

A server action needs a network round trip. For small actions – a heart, a checkbox – that feels sluggish. useOptimistic shows the result immediately and takes it back if it fails.

optimistic.tsx
"use client";
import { useOptimistic, useTransition } from "react";

export function List({ entries }: { entries: Entry[] }) {
  const [visible, add] = useOptimistic(
    entries,
    (current, text: string) => [...current, { text, sending: true }],
  );
  const [, start] = useTransition();

  function submit(formData: FormData) {
    const text = String(formData.get("text"));
    start(async () => {
      add(text);              // visible immediately
      await createEntry(text); // server action
    });
  }

  return <form action={submit}>…</form>;
}
Pitfall Only for actions that nearly always succeed

If the action fails, the optimistic entry disappears again. For a heart that is fine; for a payment it is not.

Does it stick?

5 questions on this lesson. Wrong answers show up in your stats.

Now write it yourself

A form with validation and a pending state

Finish the sign-up form using useActionState. The action already exists and simulates the server – you wire up the form, the error message and the button label. (In Next.js the action would live in a file with "use server".)

  • Without an @ the error text Please enter a valid address appears
  • With a valid address Thanks! appears
  • While submitting the button reads Sending …
import { useActionState } from "react";

type State = { error: string | null; success?: boolean };

// In Next.js this function would live in a file with "use server".
async function signUp(previous: State, formData: FormData): Promise<State> {
  const mail = String(formData.get("mail") ?? "");
  await new Promise((r) => setTimeout(r, 300));
  if (!mail.includes("@")) return { error: "Please enter a valid address" };
  return { error: null, success: true };
}

export default function App() {
  // TODO: wire up useActionState

  return (
    <form style={{ fontFamily: "system-ui", padding: 16 }}>
      <input name="mail" aria-label="mail" />{" "}
      <button>Send</button>
    </form>
  );
}

Hirefullstack

Need React firepower on your team?

We have been building React and Next.js applications for clients across Germany for years – as a single expert, as reinforcement for an existing team, or as a complete Scrum team.

Talk about your project →