next.jsreact

Server Actions in Next.js: een formulier dat werkt voordat JavaScript is geladen

Server Actions in Next.js: een formulier dat werkt voordat JavaScript is geladen

Het klassieke formulierpatroon in React: onSubmit, event.preventDefault(), fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }), useState om de laadstatus af te handelen. Het werkt – onder één voorwaarde die je makkelijk vergeet, omdat die zich op je eigen laptop met snelle verbinding nooit openbaart: JavaScript moet de tijd hebben gehad om te laden, te parsen en uit te voeren, voordat het formulier überhaupt op een klik kan reageren. Op een trage mobiele verbinding, tijdens de hydratatie van een grote pagina, of wanneer een script door een browserextensie wordt geblokkeerd – dan klikt de gebruiker op "Versturen", en er gebeurt letterlijk niets. Geen foutmelding, geen spinner – onClick bestaat gewoon nog niet.

Server Actions in Next.js worden vaak voorgesteld als een handigere syntax voor precies dezelfde aanroep – "fetch, maar dan zonder dat je een API-route hoeft te schrijven". Dat is een versimpeling die verliest wat er in dit mechanisme daadwerkelijk nieuw is: <form action={mijnActie}> steunt op het native, al sinds jaar en dag bestaande mechanisme waarmee browsers formulieren versturen, niet op JavaScript als noodzakelijke voorwaarde. Dat verandert zowel hoe het onder de motorkap werkt, als welke fouten teams in de praktijk maken die "use server" behandelen als een gewone aanroep van een lokale functie.

Wat de directive "use server" écht doet

Zodra je een functie markeert met "use server", laat de compiler van Next.js de body ervan niet achter in de bundel die de browser krijgt. Hij snijdt hem eruit en vervangt hem door een referentie – een versleutelde identifier die aangeeft welke functie op de server moet worden aangeroepen. Wat daadwerkelijk bij de client terechtkomt, is een kleine stub: "wanneer je mij aanroept, stuur een POST naar een speciaal RSC-eindpunt met deze identifier en de geserialiseerde argumenten". De eigenlijke logica – een databasequery, validatie, wat de functie ook doet – verlaat de server fysiek nooit.

tsx

// app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function addTodoAction(prevState: unknown, formData: FormData) {
const title = formData.get("title");
if (typeof title !== "string" || title.trim().length === 0) {
return { error: "De titel mag niet leeg zijn." };
}
await db.todo.create({ data: { title: title.trim() } });
revalidatePath("/todos");
return { error: null };
}

Zodra je deze functie koppelt aan <form action={addTodoAction}>, gebeurt er iets wat een gewone fetch je nooit gratis gaf: het werkt voordat er ook maar één regel JavaScript is uitgevoerd. De browser kan van oudsher een formulier versturen als een native POST, zodra het attribuut action een URL aanwijst – React 19 en Next.js breiden dit uit zodat action ook een serverfunctie kan zijn, en de browser volgt gewoon zijn native pad als JS nog niet klaar is. Zodra JavaScript wel werkt, onderschept Next.js dat evenement en doet hetzelfde via een fetch op de achtergrond, zonder de pagina te herladen, met een vloeiende UI-update. Dit is progressive enhancement ingebouwd in het framework, geen iets wat je zelf moet programmeren – exact hetzelfde mechanisme waarover we het hadden bij RouteAnnouncer in het artikel over toegankelijkheid, alleen nu toegepast op datamutatie in plaats van navigatie.

Praktisch voorbeeld: een formulier met volledige statusafhandeling, zonder handmatige fetch

React 19 voegde useActionState speciaal voor dit patroon toe – het koppelt de aanroep van een serveractie aan de status die door de laatste uitvoering ervan is teruggegeven, zonder handmatige useState om een foutmelding of laadstatus bij te houden.

tsx

"use client";
import { useActionState } from "react";
import { addTodoAction } from "./actions";
const initialState = { error: null };
const TodoForm = () => {
const [state, formAction, isPending] = useActionState(
addTodoAction,
initialState,
);
return (
<form action={formAction}>
<input type="text" name="title" placeholder="Nieuwe taak" required />
<button type="submit" disabled={isPending}>
{isPending ? "Toevoegen…" : "Toevoegen"}
</button>
{state.error && <p className="form-error">{state.error}</p>}
</form>
);
};
export default TodoForm;

Er gebeuren hier meerdere dingen tegelijk, zonder een regel code gewijd aan het handmatig beheren van de request. isPending weerspiegelt de werkelijke status van het formulier dat wordt verstuurd – inclusief die native, pre-hydratatie POST, niet alleen client-side aanroepen. state.error is rechtstreeks wat de serverfunctie heeft teruggegeven – geen JSON-respons parsen, geen try/catch rond een fetch. revalidatePath("/todos") in de actie geeft Next.js opdracht om de cache voor die route te vernieuwen, dus de takenlijst op de pagina toont de nieuwe invoer zonder handmatig de clientstate te verversen – het is het framework, niet jij, dat beslist wanneer en hoe een nieuwe RSC-payload wordt opgehaald.

Valkuilen en goede praktijken

  • Elke functie met "use server" is een publiek HTTP-eindpunt – geen privéfunctie. Dit is de meest voorkomende beveiligingsfout bij Server Actions: omdat niets in de UI er expliciet naar verwijst, lijkt hij "verborgen". Dat is hij niet – de compiler genereert er een permanente, van buitenaf aanroepbare actie-identifier voor, dus iedereen die deze te weten komt (bijvoorbeeld door netwerkverkeer in DevTools te bekijken) kan hem rechtstreeks aanroepen, buiten jouw UI en zijn aannames om. Validatie van invoergegevens en het controleren van rechten (sessie, gebruikersrol) moet binnen de actie zelf gebeuren, precies zoals bij een klassiek API-eindpunt – vertrouw nooit op het idee dat "de knop onzichtbaar is voor niet-ingelogde gebruikers".
  • Argumenten en de retourwaarde moeten serialiseerbaar zijn. Server Actions versturen data over het netwerk in het serialisatieformaat van React (rijker dan JSON – het ondersteunt onder meer Date, Map, en FormData rechtstreeks doorgegeven vanuit een formulier), maar laten nog steeds geen functies, class-instanties of DOM-referenties door. Als je .bind() gebruikt om een extra argument vast te leggen bij het aanroepen vanuit de client (addTodoAction.bind(null, listId)), onthoud dan dat listId ook serialiseerbaar moet zijn.
  • Dit blijft een echte netwerkrequest, geen lokale functieaanroep. Dat vergeet je makkelijk, omdat await mijnActie(data) syntactisch identiek oogt aan het aanroepen van een gewone JS-functie. Onder de motorkap is het altijd een volledige round-trip: serialisatie, HTTP, deserialisatie aan de andere kant. Een Server Action in een lus aanroepen bij elk getypt teken in een tekstveld (bijvoorbeeld voor live validatie) verandert je formulier in een requestgenerator, niet in een responsieve input – voor dat soort gevallen is debounce net zo noodzakelijk als bij elke andere netwerkaanroep.
  • Het ontbreken van revalidatePath/revalidateTag laat de UI achter met een verouderde cache. Next.js cachet standaard paginadata – een actie die iets in de database opslaat maar het framework niet vertelt welke route te vernieuwen, resulteert vaak in de melding "het is opgeslagen, maar ik zie het niet" – de data in de database klopt, maar de UI toont nog steeds de oude, gebufferde versie van de pagina.

Samenvatting

Server Actions zijn geen "fetch met mooiere syntax" – het zijn twee verschillende dingen, gebouwd op hetzelfde mechanisme. Ten eerste steunen ze op het native versturen van formulieren door de browser, dus werkt een formulier zelfs voordat JavaScript de tijd heeft gehad om te laden – jij hoeft geen regel code te schrijven die verantwoordelijk is voor dat gedrag, je krijgt het gratis vanuit de architectuur van <form action={...}>. Ten tweede, en belangrijker vanuit beveiligingsoogpunt: elke functie gemarkeerd met "use server" wordt een reëel, van buitenaf aanroepbaar eindpunt op het netwerk, ongeacht of enig zichtbaar UI-element hem daadwerkelijk aanroept. Hem behandelen als een lokale, vertrouwde functie – zonder validatie en autorisatie erbinnen – is de eenvoudigste manier om handige syntax te veranderen in een onbeveiligd API-eindpunt.