next.jsreact

Server Actions i Next.js: formuläret som fungerar innan JavaScript har laddats

Det klassiska formulärmönstret i React: onSubmit, event.preventDefault(), fetch('/api/todos', { method: 'POST', body: JSON.stringify(...) }), useState för att hantera laddningstillstånd. Det fungerar – under ett villkor som lätt glöms bort, eftersom det aldrig visar sig på din egen laptop med snabb uppkoppling: JavaScript måste hinna laddas, tolkas och köras innan formuläret överhuvudtaget kan reagera på ett klick. På en långsam mobiluppkoppling, mitt under hydreringen av en stor sida, eller när skriptet blockeras av en webbläsartilllägg – klickar användaren på "Skicka", och absolut ingenting händer. Inget fel, ingen laddningsindikator – onClick existerar helt enkelt ännu inte.

Server Actions i Next.js beskrivs ibland som en bekvämare syntax för exakt samma anrop – "fetch, fast du slipper skriva en API-route". Det är en förenkling som missar det som faktiskt är nytt i mekanismen: <form action={minAction}> bygger på webbläsarens naturliga, urgamla mekanism för att skicka formulär, inte på JavaScript som ett nödvändigt villkor. Det förändrar både hur det fungerar under ytan, och vilka fel team i praktiken begår när de behandlar "use server" som ett vanligt anrop till en lokal funktion.

Vad direktivet "use server" faktiskt gör

När du märker en funktion med "use server" lämnar Next.js kompilator inte kvar dess funktionskropp i paketet som skickas till webbläsaren. Den skär bort den och ersätter den med en referens – en krypterad identifierare som pekar på vilken funktion på servern som ska anropas. Det som faktiskt hamnar hos klienten är en liten stub: "när du anropar mig, skicka en POST till en särskild RSC-slutpunkt med den här identifieraren och de serialiserade argumenten". Själva logiken – en databasfråga, validering, vad funktionen nu gör – lämnar aldrig fysiskt servern.

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: "Titeln får inte vara tom." };
}
await db.todo.create({ data: { title: title.trim() } });
revalidatePath("/todos");
return { error: null };
}

När du kopplar den här funktionen till <form action={addTodoAction}> händer något som ett vanligt fetch aldrig gav dig gratis: det fungerar innan någon JavaScript alls har körts. Webbläsaren har alltid kunnat skicka ett formulär som en nativ POST om action-attributet pekar på en URL – React 19 och Next.js utökar det så att action också kan vara en serverfunktion, och webbläsaren tar ändå sin nativa väg om JS ännu inte är redo. När JavaScript väl är igång fångar Next.js upp händelsen och gör samma sak via fetch i bakgrunden, utan att sidan laddas om, med en smidig uppdatering av UI:t. Det här är progressiv förbättring inbyggd i ramverket, inte något du själv behöver programmera – exakt samma mekanism som vi skrev om i samband med RouteAnnouncer i artikeln om tillgänglighet, fast tillämpad på datamutationer i stället för navigering.

Praktiskt exempel: ett formulär med full tillståndshantering, utan manuellt fetch

React 19 lade till useActionState specifikt för det här mönstret – det kopplar ihop anropet av en serveraktion med tillståndet som returnerades av dess senaste körning, utan manuellt useState för att lagra fel eller laddningsstatus.

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="Ny uppgift" required />
<button type="submit" disabled={isPending}>
{isPending ? "Lägger till…" : "Lägg till"}
</button>
{state.error && <p className="form-error">{state.error}</p>}
</form>
);
};
export default TodoForm;

Flera saker händer här samtidigt, utan en enda rad kod ägnad åt manuell hantering av requesten. isPending speglar det faktiska tillståndet för formulärets inskickning – inklusive den nativa, förhydreringsmässiga POST:en, inte bara klientsidans anrop. state.error är direkt det som serverfunktionen returnerade – ingen tolkning av ett JSON-svar, inget try/catch runt fetch. revalidatePath("/todos") i aktionen ber Next.js att uppdatera cachen för den rutten, så uppgiftslistan på sidan visar den nya posten utan att klientens tillstånd manuellt behöver uppdateras – det är ramverket, inte du, som avgör när och hur nästa RSC-payload ska dras in.

Fallgropar och god praxis

  • Varje "use server"-funktion är en offentlig HTTP-slutpunkt – inte en privat funktion. Det är det vanligaste säkerhetsmisstaget med Server Actions: eftersom inget i UI:t länkar direkt till den verkar den "dold". Det är den inte – kompilatorn genererar en beständig, externt anropbar aktionsidentifierare för den, så vem som helst som känner till den (t.ex. genom att titta på nätverkstrafiken i DevTools) kan anropa den direkt, förbi ditt UI och dess antaganden. Validering av indata och kontroll av behörighet (session, användarroll) måste ske inuti själva aktionen, precis som i en klassisk API-slutpunkt – lita aldrig på att "knappen är osynlig för icke-inloggade".
  • Argument och returvärde måste vara serialiserbara. Server Actions skickar data över nätverket i Reacts serialiseringsformat (rikare än JSON – det stödjer bland annat Date, Map, FormData skickat direkt från formuläret), men släpper fortfarande inte igenom funktioner, klassinstanser eller referenser till DOM-objekt. Om du använder .bind() för att slå in ett extra argument vid anrop från klienten (addTodoAction.bind(null, listId)), kom ihåg att listId också måste gå att serialisera.
  • Det är fortfarande en riktig nätverksförfrågan, inte ett lokalt funktionsanrop. Det är lätt att glömma, eftersom await minAction(data) syntaktiskt ser identiskt ut med ett vanligt JS-funktionsanrop. Under ytan är det alltid en fullständig tur och retur: serialisering, HTTP, deserialisering på andra sidan. Att anropa en Server Action i en loop för varje tecken som skrivs i ett textfält (t.ex. för löpande validering) förvandlar ditt formulär till en requestgenerator, inte till ett responsivt fält – för den typen av fall är debounce lika nödvändigt som vid vilket annat nätverksanrop som helst.
  • Utebliven revalidatePath/revalidateTag lämnar UI:t med en föråldrad cache. Next.js cachar som standard sidodata – en aktion som sparar något i databasen men inte talar om för ramverket vilken rutt som ska uppdateras leder ofta till "det sparades, men syns inte" – datan är korrekt i databasen, men UI:t visar fortfarande den gamla, cachade versionen av sidan.

Sammanfattning

Server Actions är inte "fetch med snyggare syntax" – det är två olika saker byggda på samma mekanism. För det första bygger de på webbläsarens nativa formulärinskickning, så formuläret fungerar även innan JavaScript hunnit laddas – du behöver inte skriva en enda rad kod för det beteendet, du får det från själva arkitekturen i <form action={...}>. För det andra, och viktigare ur säkerhetssynpunkt: varje funktion märkt "use server" blir en verklig, externt anropbar slutpunkt på nätet, oavsett om något synligt UI-element anropar den eller inte. Att behandla den som en lokal, betrodd funktion – utan validering och auktorisering inuti – är det enklaste sättet att förvandla en bekväm syntax till en osäkrad API-slutpunkt.