Server Actions en Formulieren in Next.js App Router: De Complete Gids

Alles over Server Actions en formulierverwerking in Next.js App Router. Van "use server" basics tot Zod-validatie, useActionState, optimistic updates, beveiliging en cache-invalidatie met praktische codevoorbeelden.

Introductie: Data Mutaties in de App Router

In een eerder artikel hebben we uitgebreid behandeld hoe je data ophaalt in de Next.js App Router — met Server Components, streaming, caching en meer. Maar eerlijk gezegd, een applicatie draait niet alleen om lezen. Gebruikers vullen formulieren in, plaatsen bestellingen, updaten hun profiel, verwijderen items. Kortom: ze muteren data. En dat is precies waar het interessant wordt.

Traditioneel had je daar API-routes voor nodig. Je maakte een POST /api/contact-endpoint, schreef de handler, deed een fetch vanuit de client, handelde errors af, stuurde de gebruiker door... het werkte, maar het was een hoop boilerplate voor iets wat eigenlijk vrij simpel zou moeten zijn. Eerlijk? Het voelde alsof je elke keer hetzelfde recept aan het herhalen was.

Met Server Actions in Next.js is dat verhaal compleet veranderd. Een Server Action is een asynchrone functie die op de server draait en die je direct kunt aanroepen vanuit je componenten — zonder zelf een API-endpoint te schrijven. Next.js regelt de HTTP-request, de serialisatie, de foutafhandeling en zelfs progressive enhancement onder de motorkap.

Klinkt te mooi om waar te zijn? Dat dacht ik ook in het begin. Maar het werkt echt verrassend goed.

In dit artikel nemen we je mee door alles wat je moet weten over Server Actions en formulierverwerking in de Next.js App Router (Next.js 16). Van de basis tot geavanceerde patronen zoals optimistic updates, Zod-validatie, beveiliging en meer. Dit is het natuurlijke vervolg op data ophalen: nu gaan we data schrijven.

Wat Zijn Server Actions Precies?

Een Server Action is een functie gemarkeerd met de "use server"-directive. Je kunt die directive op twee plekken zetten:

  • Bovenaan een functie — om die specifieke functie als Server Action te markeren.
  • Bovenaan een apart bestand — om alle exports uit dat bestand als Server Actions te behandelen.

Onder de motorkap creëert Next.js voor elke Server Action een uniek, versleuteld HTTP POST-endpoint. Wanneer een formulier wordt ingediend of een action wordt aangeroepen, stuurt de browser een POST-request naar dat endpoint. Next.js deserialiseert de data, voert de functie uit op de server, en stuurt het resultaat terug naar de client. De functie zelf wordt nooit als JavaScript naar de browser gestuurd — en dat is best een prettig idee als je erover nadenkt.

Je Eerste Server Action

Goed, laten we beginnen met het simpelste voorbeeld: een contactformulier.

// app/contact/page.tsx
export default function ContactPage() {
  async function verstuurBericht(formData: FormData) {
    "use server";

    const naam = formData.get("naam") as string;
    const email = formData.get("email") as string;
    const bericht = formData.get("bericht") as string;

    // Sla op in database, stuur email, etc.
    await db.berichten.create({
      data: { naam, email, bericht },
    });
  }

  return (
    <form action={verstuurBericht}>
      <input type="text" name="naam" required />
      <input type="email" name="email" required />
      <textarea name="bericht" required />
      <button type="submit">Verstuur</button>
    </form>
  );
}

Dat is het. Geen API-route, geen fetch-call, geen useState voor loading states. De action-prop van het formulier wijst direct naar de Server Action. Bij het indienen wordt de functie op de server uitgevoerd.

En het mooiste? Dit formulier werkt zelfs zonder JavaScript. Progressive enhancement uit de doos — daar word je toch blij van.

Server Actions Organiseren: Aparte Bestanden

In het voorbeeld hierboven staat de Server Action inline in een Server Component. Dat is prima voor eenvoudige gevallen, maar voor grotere applicaties wil je je actions in aparte bestanden organiseren. Dit is trouwens ook noodzakelijk als je een Server Action wilt gebruiken in een Client Component — Client Components kunnen namelijk geen inline "use server"-functies bevatten.

// app/actions/berichten.ts
"use server";

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function verstuurBericht(formData: FormData) {
  const naam = formData.get("naam") as string;
  const email = formData.get("email") as string;
  const bericht = formData.get("bericht") as string;

  await db.berichten.create({
    data: { naam, email, bericht },
  });

  revalidatePath("/berichten");
}

export async function verwijderBericht(id: string) {
  await db.berichten.delete({ where: { id } });
  revalidatePath("/berichten");
}

Door "use server" bovenaan het bestand te plaatsen, worden alle geëxporteerde functies automatisch Server Actions. Nu kun je ze importeren in zowel Server als Client Components — super handig.

// app/contact/page.tsx
import { verstuurBericht } from "@/app/actions/berichten";

export default function ContactPage() {
  return (
    <form action={verstuurBericht}>
      {/* formuliervelden */}
      <button type="submit">Verstuur</button>
    </form>
  );
}

Formulierstatus Beheren met useActionState

Het eenvoudige voorbeeld hierboven heeft een probleem: de gebruiker krijgt geen feedback. Geen laadstatus, geen succesbericht, geen foutmelding. In een productie-applicatie is dat, nou ja, niet echt acceptabel.

React 19 introduceert de useActionState-hook — de aanbevolen manier om formulierstatus te beheren bij Server Actions. Deze hook geeft je drie dingen: de huidige state (met je action-resultaat), een geüpdatete action-functie om aan je formulier te geven, en een pending-boolean die aangeeft of het formulier wordt verwerkt.

// app/contact/contact-form.tsx
"use client";

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

interface FormState {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
}

const initialState: FormState = {
  success: false,
  message: "",
};

export function ContactFormulier() {
  const [state, formAction, isPending] = useActionState(
    verstuurBericht,
    initialState
  );

  return (
    <form action={formAction}>
      {state.message && (
        <div
          className={state.success ? "text-green-600" : "text-red-600"}
          role="alert"
        >
          {state.message}
        </div>
      )}

      <div>
        <label htmlFor="naam">Naam</label>
        <input
          type="text"
          id="naam"
          name="naam"
          required
          aria-describedby={state.errors?.naam ? "naam-error" : undefined}
        />
        {state.errors?.naam && (
          <p id="naam-error" className="text-red-500 text-sm">
            {state.errors.naam[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="email">E-mailadres</label>
        <input type="email" id="email" name="email" required />
        {state.errors?.email && (
          <p className="text-red-500 text-sm">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="bericht">Bericht</label>
        <textarea id="bericht" name="bericht" required />
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "Verzenden..." : "Verstuur bericht"}
      </button>
    </form>
  );
}

De bijbehorende Server Action moet nu de vorige state als eerste parameter accepteren:

// app/actions/berichten.ts
"use server";

export async function verstuurBericht(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const naam = formData.get("naam") as string;
  const email = formData.get("email") as string;
  const bericht = formData.get("bericht") as string;

  try {
    await db.berichten.create({
      data: { naam, email, bericht },
    });

    return {
      success: true,
      message: "Bedankt! Je bericht is verstuurd.",
    };
  } catch (error) {
    return {
      success: false,
      message: "Er ging iets mis. Probeer het opnieuw.",
    };
  }
}

Belangrijk detail: useActionState is een Client Component hook. Je formuliercomponent moet dus "use client" zijn. Maar de Server Action zelf draait nog steeds op de server — alleen de aanroep ervan komt vanuit de client. Dat onderscheid is makkelijk te vergeten, maar wel essentieel.

Pendingstatus Tonen met useFormStatus

Naast useActionState biedt React 19 ook de useFormStatus-hook. Het verschil? useFormStatus is specifiek ontworpen voor submit-knoppen en moet een kind zijn van het <form>-element. Het is ideaal als je een herbruikbare submit-knop wilt maken die je door je hele app kunt gebruiken.

// components/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

interface SubmitButtonProps {
  children: React.ReactNode;
  pendingText?: string;
}

export function SubmitButton({
  children,
  pendingText = "Bezig...",
}: SubmitButtonProps) {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className={`px-4 py-2 rounded ${
        pending ? "bg-gray-400" : "bg-blue-600 hover:bg-blue-700"
      } text-white transition-colors`}
    >
      {pending ? pendingText : children}
    </button>
  );
}

Het grote voordeel van useFormStatus is dat het in elke vorm gebruikt kan worden — je hoeft het niet te koppelen aan een specifieke action. Zolang het component een kind is van een <form>, werkt het.

Dus wanneer gebruik je welke hook? Kort samengevat: gebruik useActionState wanneer je het resultaat van de action nodig hebt (succes/fout-berichten, validatiefouten). Gebruik useFormStatus voor simpele pending-indicators in herbruikbare submit-knoppen.

Server-side Validatie met Zod

Oké, dit is een cruciaal onderdeel dat veel ontwikkelaars over het hoofd zien. Elke Server Action is een publiek HTTP POST-endpoint. Laat dat even bezinken. Dat betekent dat iemand met een tool als cURL of Postman direct je action kan aanroepen met willekeurige data — volledig buiten je mooie formulier om. TypeScript-types bieden hier geen bescherming, want die bestaan alleen tijdens compile-time.

Daarom moet je altijd server-side validatie toepassen. Zod is hier de standaardkeuze in het Next.js-ecosysteem, en eerlijk gezegd begrijp ik waarom — het is gewoon prettig om mee te werken.

// lib/validatie/berichten.ts
import { z } from "zod";

export const berichtSchema = z.object({
  naam: z
    .string()
    .min(2, "Naam moet minimaal 2 tekens bevatten")
    .max(100, "Naam mag maximaal 100 tekens bevatten"),
  email: z
    .string()
    .email("Ongeldig e-mailadres"),
  bericht: z
    .string()
    .min(10, "Bericht moet minimaal 10 tekens bevatten")
    .max(5000, "Bericht mag maximaal 5000 tekens bevatten"),
});

export type BerichtInput = z.infer<typeof berichtSchema>;
// app/actions/berichten.ts
"use server";

import { berichtSchema } from "@/lib/validatie/berichten";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

interface FormState {
  success: boolean;
  message: string;
  errors?: Record<string, string[]>;
}

export async function verstuurBericht(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  // Valideer de input met Zod
  const validatie = berichtSchema.safeParse({
    naam: formData.get("naam"),
    email: formData.get("email"),
    bericht: formData.get("bericht"),
  });

  if (!validatie.success) {
    return {
      success: false,
      message: "Controleer de ingevulde gegevens.",
      errors: validatie.error.flatten().fieldErrors,
    };
  }

  try {
    await db.berichten.create({
      data: validatie.data,
    });

    revalidatePath("/berichten");

    return {
      success: true,
      message: "Bedankt! Je bericht is succesvol verstuurd.",
    };
  } catch (error) {
    return {
      success: false,
      message: "Er is een serverfout opgetreden. Probeer het later opnieuw.",
    };
  }
}

Een belangrijk voordeel van Zod is dat je hetzelfde schema kunt gebruiken voor zowel client-side als server-side validatie. Zo heb je één single source of truth voor je validatieregels. Op de client geeft het directe feedback, op de server biedt het echte beveiliging. Win-win.

Client-side Validatie Toevoegen

Voor een optimale gebruikerservaring wil je ook client-side validatie. Het mooie is: je kunt gewoon hetzelfde Zod-schema hergebruiken.

// app/contact/contact-form.tsx
"use client";

import { useActionState, useState } from "react";
import { berichtSchema } from "@/lib/validatie/berichten";
import { verstuurBericht } from "@/app/actions/berichten";

export function ContactFormulier() {
  const [state, formAction, isPending] = useActionState(
    verstuurBericht,
    { success: false, message: "" }
  );
  const [clientErrors, setClientErrors] = useState<
    Record<string, string[]>
  >({});

  function handleValidatie(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) {
    const { name, value } = e.target;
    const result = berichtSchema.shape[name as keyof typeof berichtSchema.shape]
      ?.safeParse(value);

    if (result && !result.success) {
      setClientErrors((prev) => ({
        ...prev,
        [name]: result.error.errors.map((err) => err.message),
      }));
    } else {
      setClientErrors((prev) => {
        const next = { ...prev };
        delete next[name];
        return next;
      });
    }
  }

  // Combineer client- en serverfouten
  const errors = { ...clientErrors, ...state.errors };

  return (
    <form action={formAction}>
      <input
        type="text"
        name="naam"
        onBlur={handleValidatie}
        aria-invalid={!!errors.naam}
      />
      {errors.naam && <p className="text-red-500">{errors.naam[0]}</p>}

      {/* meer velden... */}
    </form>
  );
}

Beveiliging: Authenticatie en Autorisatie

Ik kan het niet genoeg benadrukken: elke Server Action is een publiek endpoint. Dit betekent dat je in elke action expliciet moet controleren of de gebruiker is ingelogd en of die gebruiker deze actie mag uitvoeren. Vertrouw niet op pagina-level authenticatie — een aanvaller kan de action rechtstreeks aanroepen.

// app/actions/producten.ts
"use server";

import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { revalidateTag } from "next/cache";

export async function updateProduct(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  // 1. Authenticatie: is de gebruiker ingelogd?
  const sessie = await auth();
  if (!sessie?.user) {
    return {
      success: false,
      message: "Je moet ingelogd zijn om dit te doen.",
    };
  }

  const productId = formData.get("productId") as string;

  // 2. Autorisatie: mag deze gebruiker dit product bewerken?
  const product = await db.producten.findUnique({
    where: { id: productId },
  });

  if (!product || product.eigenaarId !== sessie.user.id) {
    return {
      success: false,
      message: "Je hebt geen toestemming om dit product te bewerken.",
    };
  }

  // 3. Validatie: is de input geldig?
  const validatie = productSchema.safeParse({
    naam: formData.get("naam"),
    prijs: Number(formData.get("prijs")),
    beschrijving: formData.get("beschrijving"),
  });

  if (!validatie.success) {
    return {
      success: false,
      message: "Ongeldige invoer.",
      errors: validatie.error.flatten().fieldErrors,
    };
  }

  // 4. Mutatie uitvoeren
  await db.producten.update({
    where: { id: productId },
    data: validatie.data,
  });

  revalidateTag("producten");

  return {
    success: true,
    message: "Product succesvol bijgewerkt.",
  };
}

Het patroon is altijd hetzelfde: authenticatie → autorisatie → validatie → mutatie. Sla geen van deze stappen over, ook niet als het "maar een simpele action" lijkt. Je toekomstige zelf zal je dankbaar zijn.

Een extra beveiligingslaag: geef nooit interne foutmeldingen terug. Als een database-query faalt, toon dan een generiek bericht aan de gebruiker. Interne details (stacktraces, query-fouten) horen in je server-logs, niet in je API-response.

Cache Invalidatie: revalidatePath en revalidateTag

Na een succesvolle mutatie wil je dat de gebruiker de bijgewerkte data ziet. Logisch, toch? Next.js biedt twee functies om de cache te invalideren:

  • revalidatePath(path) — invalideert een specifiek pad (pagina of layout).
  • revalidateTag(tag) — invalideert alle data die gemarkeerd is met een bepaalde tag.
// Met revalidatePath — invalideert een specifieke pagina
import { revalidatePath } from "next/cache";

export async function maakBlogPost(formData: FormData) {
  "use server";
  await db.posts.create({ data: { /* ... */ } });

  // Invalideert de blogpagina zodat de nieuwe post verschijnt
  revalidatePath("/blog");
}

// Met revalidateTag — invalideert alle data met die tag
import { revalidateTag } from "next/cache";

export async function updateProfiel(formData: FormData) {
  "use server";
  await db.users.update({ where: { /* ... */ }, data: { /* ... */ } });

  // Invalideert alle data getagd met "gebruiker-profiel"
  revalidateTag("gebruiker-profiel");
}

Wanneer gebruik je welke? revalidatePath is eenvoudig en direct — perfect als je precies weet welke pagina vernieuwd moet worden. revalidateTag is krachtiger en flexibeler — het invalideert data op alle pagina's die die tag gebruiken. Vooral handig wanneer dezelfde data op meerdere plekken wordt getoond (en dat komt vaker voor dan je denkt).

Optimistic Updates met useOptimistic

Soms wil je de UI onmiddellijk bijwerken, nog voordat de server heeft gereageerd. Denk aan een like-knop, een takenlijst waarbij items direct verschijnen, of een chat-interface. React 19's useOptimistic-hook maakt dit mogelijk.

// app/taken/taken-lijst.tsx
"use client";

import { useOptimistic, useRef } from "react";
import { voegTaakToe } from "@/app/actions/taken";

interface Taak {
  id: string;
  titel: string;
  voltooid: boolean;
}

export function TakenLijst({ taken }: { taken: Taak[] }) {
  const formRef = useRef<HTMLFormElement>(null);

  const [optimisticTaken, addOptimisticTaak] = useOptimistic(
    taken,
    (huidige: Taak[], nieuweTitel: string) => [
      ...huidige,
      {
        id: `temp-${Date.now()}`,
        titel: nieuweTitel,
        voltooid: false,
      },
    ]
  );

  async function handleSubmit(formData: FormData) {
    const titel = formData.get("titel") as string;

    // Update de UI direct (optimistisch)
    addOptimisticTaak(titel);
    formRef.current?.reset();

    // Voer de daadwerkelijke mutatie uit op de server
    await voegTaakToe(formData);
  }

  return (
    <div>
      <ul>
        {optimisticTaken.map((taak) => (
          <li
            key={taak.id}
            className={taak.id.startsWith("temp-") ? "opacity-60" : ""}
          >
            {taak.titel}
            {taak.id.startsWith("temp-") && (
              <span className="text-sm text-gray-400 ml-2">Opslaan...</span>
            )}
          </li>
        ))}
      </ul>

      <form ref={formRef} action={handleSubmit}>
        <input type="text" name="titel" placeholder="Nieuwe taak..." required />
        <button type="submit">Toevoegen</button>
      </form>
    </div>
  );
}

Het item verschijnt meteen in de lijst (met een visuele indicator dat het nog bezig is) en wordt vervangen door de echte data zodra de server reageert. Als de server een fout retourneert, wordt de optimistische update automatisch teruggedraaid. Netjes, toch?

Dit patroon is eerlijk gezegd een gamechanger voor de gebruikerservaring. De applicatie voelt instant aan, terwijl de data-integriteit gegarandeerd blijft doordat de server het laatste woord heeft.

Redirects en Cookies in Server Actions

Server Actions kunnen meer dan alleen data muteren. Je kunt er ook mee redirecten, cookies instellen en headers lezen. Laten we de belangrijkste patronen doornemen.

Redirect na een Succesvolle Mutatie

// app/actions/auth.ts
"use server";

import { redirect } from "next/navigation";
import { cookies } from "next/headers";

export async function login(prevState: FormState, formData: FormData) {
  const email = formData.get("email") as string;
  const wachtwoord = formData.get("wachtwoord") as string;

  const gebruiker = await authenticateUser(email, wachtwoord);

  if (!gebruiker) {
    return {
      success: false,
      message: "Ongeldig e-mailadres of wachtwoord.",
    };
  }

  // Stel een sessie-cookie in
  const cookieStore = await cookies();
  cookieStore.set("session", gebruiker.sessionToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 7, // 7 dagen
  });

  // Redirect naar het dashboard
  // Let op: code na redirect() wordt NIET uitgevoerd
  redirect("/dashboard");
}

Belangrijk om te weten: redirect() gooit intern een exception die door Next.js wordt afgehandeld. Code die ná de redirect-aanroep staat, wordt niet uitgevoerd. Zorg er dus voor dat je revalidatePath of revalidateTag vóór de redirect aanroept als je de cache wilt invalideren. Dit is een klassieke valkuil waar ik zelf ook een keer op vastgelopen ben.

Cookies Lezen en Schrijven

In Server Actions heb je volledige toegang tot de cookies()-functie van next/headers. Dit is handig voor sessie-management, themavoorkeur, taalinstelling en meer.

// app/actions/voorkeuren.ts
"use server";

import { cookies } from "next/headers";

export async function setThema(formData: FormData) {
  const thema = formData.get("thema") as "licht" | "donker";

  const cookieStore = await cookies();
  cookieStore.set("thema", thema, {
    httpOnly: false, // Toegankelijk via JavaScript voor theme-switching
    maxAge: 60 * 60 * 24 * 365, // 1 jaar
    path: "/",
  });
}

export async function setTaal(formData: FormData) {
  const taal = formData.get("taal") as string;

  const cookieStore = await cookies();
  cookieStore.set("taal", taal, {
    httpOnly: true,
    maxAge: 60 * 60 * 24 * 365,
    path: "/",
  });
}

Even een belangrijke kanttekening: cookies instellen kan alleen in Server Actions en Route Handlers. In Server Components kun je cookies wel lezen, maar niet schrijven. Waarom? Omdat het instellen van cookies de Set-Cookie-header in de HTTP-response vereist, en die header kan alleen worden ingesteld wanneer je een response terugstuurt — wat bij een Server Action of Route Handler het geval is.

De Next.js Form Component: next/form

Naast het standaard HTML <form>-element biedt Next.js ook een <Form>-component uit next/form. Deze component is specifiek ontworpen voor navigatie-formulieren — denk aan zoekformulieren die de URL-parameters bijwerken.

// app/zoeken/page.tsx
import Form from "next/form";

export default function ZoekPage() {
  return (
    <div>
      <h1>Producten Zoeken</h1>
      <Form action="/zoeken/resultaten">
        <input
          type="text"
          name="q"
          placeholder="Zoek producten..."
        />
        <select name="categorie">
          <option value="">Alle categorieën</option>
          <option value="elektronica">Elektronica</option>
          <option value="kleding">Kleding</option>
        </select>
        <button type="submit">Zoeken</button>
      </Form>
    </div>
  );
}

Wanneer de action-prop een string (URL) is, gedraagt <Form> zich als een GET-formulier: de invoerwaarden worden als zoekparameters aan de URL toegevoegd. Maar anders dan een gewoon HTML-formulier doet het dit via client-side navigatie in plaats van een volledige paginaherlaad. Bovendien prefetcht het de bestemmingspagina voor snellere navigatie.

Zonder JavaScript werkt het gewoon als een normaal HTML-formulier. Progressive enhancement in actie — ik blijf het mooi vinden.

// app/zoeken/resultaten/page.tsx
interface ResultatenPageProps {
  searchParams: Promise<{
    q?: string;
    categorie?: string;
  }>;
}

export default async function ResultatenPage({
  searchParams,
}: ResultatenPageProps) {
  const params = await searchParams;
  const query = params.q || "";
  const categorie = params.categorie || "";

  const resultaten = await db.producten.findMany({
    where: {
      naam: { contains: query, mode: "insensitive" },
      ...(categorie ? { categorie } : {}),
    },
    take: 20,
  });

  return (
    <div>
      <h1>Resultaten voor "{query}"</h1>
      {resultaten.length === 0 ? (
        <p>Geen producten gevonden.</p>
      ) : (
        <ul>
          {resultaten.map((product) => (
            <li key={product.id}>{product.naam} — €{product.prijs}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

Server Actions Buiten Formulieren

Server Actions zijn niet beperkt tot formulieren. Je kunt ze ook programmatisch aanroepen vanuit event handlers, effecten of andere functies in Client Components. Dit is handig voor acties die niet door een formulierinzending worden getriggerd.

// components/like-knop.tsx
"use client";

import { useTransition } from "react";
import { toggleLike } from "@/app/actions/likes";

export function LikeKnop({
  postId,
  isLiked,
}: {
  postId: string;
  isLiked: boolean;
}) {
  const [isPending, startTransition] = useTransition();

  function handleClick() {
    startTransition(async () => {
      await toggleLike(postId);
    });
  }

  return (
    <button
      onClick={handleClick}
      disabled={isPending}
      className={`${isLiked ? "text-red-500" : "text-gray-400"} ${
        isPending ? "opacity-50" : ""
      }`}
    >
      {isLiked ? "♥" : "♡"} {isPending && "..."}
    </button>
  );
}

Let op het gebruik van useTransition hier. Dit zorgt ervoor dat de pending state correct wordt bijgehouden en dat de UI responsief blijft terwijl de action wordt uitgevoerd.

Er is wel een belangrijk verschil met formulier-gebonden actions: wanneer je een Server Action programmatisch aanroept, krijg je geen progressive enhancement. Als JavaScript uitstaat, werkt de like-knop simpelweg niet. Gebruik daarom formulieren waar mogelijk — ook voor ogenschijnlijk eenvoudige acties als likes of deletes.

// Progressive enhancement-friendly versie van een delete-knop
import { verwijderItem } from "@/app/actions/items";

export function VerwijderKnop({ itemId }: { itemId: string }) {
  return (
    <form action={verwijderItem}>
      <input type="hidden" name="itemId" value={itemId} />
      <button type="submit" className="text-red-600 hover:text-red-800">
        Verwijderen
      </button>
    </form>
  );
}

Foutafhandeling en Error Boundaries

Er zijn twee soorten fouten in Server Actions: verwachte fouten (validatiefouten, autorisatieproblemen) en onverwachte fouten (databasecrashes, netwerkproblemen). De aanpak verschilt, en het is belangrijk dat verschil te begrijpen.

Verwachte Fouten: Retourneer als State

Verwachte fouten retourneer je als onderdeel van de action-state. Dit hebben we al gezien: als de validatie faalt, retourneer je een object met success: false en de relevante foutmeldingen. Simpel en effectief.

Onverwachte Fouten: Gooi een Exception

Bij onverwachte fouten kun je een exception gooien. Next.js vangt deze op via de dichtstbijzijnde error.tsx-boundary:

// app/actions/bestellingen.ts
"use server";

export async function plaatsBestelling(formData: FormData) {
  const sessie = await auth();
  if (!sessie) {
    throw new Error("Niet geautoriseerd");
  }

  try {
    const bestelling = await db.bestellingen.create({
      data: { /* ... */ },
    });

    revalidatePath("/bestellingen");
    redirect(`/bestellingen/${bestelling.id}/bevestiging`);
  } catch (error) {
    // Log de fout intern
    console.error("Bestelling mislukt:", error);

    // Retourneer een generieke foutmelding — geen interne details!
    throw new Error(
      "Er is een fout opgetreden bij het plaatsen van je bestelling."
    );
  }
}
// app/bestellingen/error.tsx
"use client";

export default function BestellingenError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="p-8 text-center">
      <h2 className="text-xl font-bold text-red-600">
        Er ging iets mis
      </h2>
      <p className="mt-2 text-gray-600">{error.message}</p>
      <button
        onClick={reset}
        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
      >
        Probeer opnieuw
      </button>
    </div>
  );
}

Extra Parameters Doorgeven met bind

Soms heb je extra parameters nodig in je Server Action die niet uit het formulier komen — zoals een record-ID. Je kunt .bind() gebruiken om deze toe te voegen. Dit is trouwens een patroon dat je waarschijnlijk al kent uit vanilla JavaScript.

// app/producten/[id]/bewerk-formulier.tsx
"use client";

import { updateProduct } from "@/app/actions/producten";

export function BewerkProductFormulier({ productId }: { productId: string }) {
  const updateProductMetId = updateProduct.bind(null, productId);

  return (
    <form action={updateProductMetId}>
      <input type="text" name="naam" />
      <input type="number" name="prijs" step="0.01" />
      <button type="submit">Opslaan</button>
    </form>
  );
}
// app/actions/producten.ts
"use server";

export async function updateProduct(
  productId: string,
  formData: FormData
) {
  // productId is beschikbaar als eerste parameter
  const naam = formData.get("naam") as string;
  const prijs = Number(formData.get("prijs"));

  await db.producten.update({
    where: { id: productId },
    data: { naam, prijs },
  });

  revalidateTag("producten");
}

Dit is veiliger dan een verborgen input-veld omdat de gebonden waarde niet door de client kan worden gemanipuleerd — het wordt versleuteld als onderdeel van de action-referentie. Een subtiel maar belangrijk verschil.

Praktijkvoorbeeld: Een Compleet CRUD-systeem

Goed, laten we nu alles samenvoegen in een realistisch voorbeeld: een takenbeheersysteem met toevoegen, bewerken, voltooien en verwijderen. Zo zie je hoe alle puzzelstukjes in de praktijk samenkomen.

// app/actions/taken.ts
"use server";

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

const taakSchema = z.object({
  titel: z.string().min(1, "Titel is verplicht").max(200),
  beschrijving: z.string().max(1000).optional(),
  prioriteit: z.enum(["laag", "normaal", "hoog"]),
});

export async function voegTaakToe(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const sessie = await auth();
  if (!sessie?.user) {
    return { success: false, message: "Niet ingelogd." };
  }

  const validatie = taakSchema.safeParse({
    titel: formData.get("titel"),
    beschrijving: formData.get("beschrijving"),
    prioriteit: formData.get("prioriteit"),
  });

  if (!validatie.success) {
    return {
      success: false,
      message: "Ongeldige invoer.",
      errors: validatie.error.flatten().fieldErrors,
    };
  }

  await db.taken.create({
    data: {
      ...validatie.data,
      gebruikerId: sessie.user.id,
    },
  });

  revalidatePath("/taken");
  return { success: true, message: "Taak toegevoegd!" };
}

export async function toggleTaakStatus(taakId: string) {
  const sessie = await auth();
  if (!sessie?.user) throw new Error("Niet ingelogd");

  const taak = await db.taken.findUnique({ where: { id: taakId } });
  if (!taak || taak.gebruikerId !== sessie.user.id) {
    throw new Error("Niet geautoriseerd");
  }

  await db.taken.update({
    where: { id: taakId },
    data: { voltooid: !taak.voltooid },
  });

  revalidatePath("/taken");
}

export async function verwijderTaak(taakId: string) {
  const sessie = await auth();
  if (!sessie?.user) throw new Error("Niet ingelogd");

  const taak = await db.taken.findUnique({ where: { id: taakId } });
  if (!taak || taak.gebruikerId !== sessie.user.id) {
    throw new Error("Niet geautoriseerd");
  }

  await db.taken.delete({ where: { id: taakId } });
  revalidatePath("/taken");
}

Veelgemaakte Fouten en Best Practices

Tot slot een overzicht van de meest voorkomende valkuilen. Ik heb ze allemaal wel eens gemaakt (of gezien), dus hopelijk helpt dit je om ze te vermijden.

1. Geen Server-side Validatie

Client-side validatie is voor de UX. Server-side validatie is voor de beveiliging. Gebruik altijd Zod of een vergelijkbare library om input te valideren in je Server Actions. TypeScript-types beschermen je niet runtime — dat is een les die je liever niet op de harde manier wilt leren.

2. Vergeten Authenticatie te Controleren

Elke Server Action is een publiek endpoint. Controleer in elke action of de gebruiker is geauthenticeerd en geautoriseerd. Vertrouw niet op middleware of pagina-level checks alleen.

3. Geen Pending State Tonen

Gebruikers klikken dubbel als ze geen feedback krijgen. Gebruik useActionState of useFormStatus om altijd een laadstatus te tonen. Het kost je vijf minuten en bespaart je gebruikers een hoop frustratie.

4. Interne Fouten Naar de Client Sturen

Retourneer nooit database-foutmeldingen, stacktraces of interne details naar de client. Dit is een beveiligingsrisico. Log intern, toon generiek.

5. Cache Niet Invalideren Na Mutaties

Na een succesvolle mutatie moet je altijd revalidatePath of revalidateTag aanroepen. Anders ziet de gebruiker verouderde data — en dat leidt gegarandeerd tot verwarrende bugrapporten.

6. Alle Actions Inline Definiëren

Inline actions in Server Components werken, maar schalen niet goed. Organiseer je actions in aparte bestanden met "use server" bovenaan voor een overzichtelijke codebase. Je teamgenoten zullen je dankbaar zijn.

Samenvatting

Server Actions zijn een fundamentele verschuiving in hoe we data mutaties afhandelen in Next.js. Ze elimineren de noodzaak voor handmatige API-routes, bieden progressive enhancement uit de doos, en integreren naadloos met React 19's nieuwe hooks voor formulierbeheer.

De kernconcepten om mee te nemen:

  • Server Actions zijn asynchrone functies met "use server" die automatisch HTTP POST-endpoints worden.
  • useActionState beheert formulierstatus inclusief pending state, succes- en foutmeldingen.
  • useFormStatus is ideaal voor herbruikbare submit-knoppen met pending-indicators.
  • useOptimistic maakt instant UI-updates mogelijk terwijl de server verwerkt.
  • Zod-validatie is essentieel — elke action is een publiek endpoint.
  • Authenticatie en autorisatie moeten in elke action gecontroleerd worden.
  • revalidatePath/revalidateTag invalideren de cache na mutaties.
  • De next/form <Form>-component is specifiek voor navigatie-formulieren met client-side routing.

In combinatie met het eerdere artikel over data ophalen heb je nu een compleet beeld van de data-flow in de Next.js App Router: ophalen met Server Components, muteren met Server Actions, en alles daar tussenin. De App Router geeft je een krachtig, server-first framework dat zowel de ontwikkelaarservaring als de eindgebruikerservaring naar een hoger niveau tilt. En eerlijk? Ik denk dat we over een paar jaar terugkijken en ons afvragen hoe we het ooit zonder deden.

Over de Auteur Editorial Team

Our team of expert writers and editors.