Server Actions und die drei üblichen Fehler
Eine Server Action ist ein öffentlicher HTTP-Endpunkt mit generiertem Namen. Sie als Funktionsaufruf zu behandeln, ist der Weg, auf dem Autorisierung und Validierung ausfallen.
Eine Server Action sieht aus wie eine Funktion. Man schreibt async function,
markiert sie mit 'use server', übergibt sie einem Formular, und sie läuft auf
dem Server. Die Ergonomie ist gut genug, dass man aufhört, darüber
nachzudenken, was tatsächlich passiert ist.
Passiert ist: Next.js hat einen HTTP-Endpunkt erzeugt, ihm eine undurchsichtige Id gegeben und das Formular darauf POSTen lassen. Wer Ihr JavaScript-Bundle lesen kann, kann ihn aufrufen - mit beliebigen Argumenten, von überall.
Jeder der folgenden Fehler folgt daraus, diesen einen Satz zu vergessen.
Erstens: den Aufrufer für vertrauenswürdig halten
Diese Fassung geht am häufigsten in Produktion:
'use server';
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}Die Komponente rendert den Button nur für Autoren, also - so die Überlegung -
kann ihn auch nur ein Autor aufrufen. So funktioniert das nicht. Den Endpunkt
gibt es unabhängig davon, ob der Button gerendert wurde, und id ist, was der
Aufrufer schickt.
Autorisieren Sie in der Action selbst, jedes Mal, gegen die Session und nicht gegen die Argumente:
'use server';
export async function deletePost(id: string) {
const session = await auth();
if (!session) throw new Error('Unauthorised');
// Die Eigentümerschaft wird in der Query geprüft, nicht gelesen und dann
// verglichen - zwei Anweisungen lassen ein Fenster zwischen Lesen und Löschen.
const deleted = await db.post.deleteMany({
where: { id, authorId: session.user.id },
});
if (deleted.count === 0) throw new Error('Not found');
revalidatePath('/posts');
}Unsere Review-Regel: die erste Anweisung einer Action ist der Session-Lookup. Steht dort etwas anderes, ist die Action falsch, bis jemand erklärt, warum nicht.
Zweitens: in der Komponente validieren
Clientseitige Validierung ist eine Bequemlichkeit für die tippende Person. Sie
ist keine Validierung. Die Action bekommt FormData direkt von der Leitung
und muss sie parsen:
'use server';
import { z } from 'zod';
const Schema = z.object({
email: z.string().email(),
message: z.string().min(20).max(5000),
});
export async function submit(prevState: State, formData: FormData) {
const parsed = Schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors };
}
await sendToInbox(parsed.data);
return { ok: true };
}Die Fehler zurückzugeben statt zu werfen ist das, was das Formular ohne JavaScript benutzbar und mit JavaScript besser macht:
'use client';
import { useActionState } from 'react';
export function ContactForm() {
const [state, action, pending] = useActionState(submit, {});
return (
<form action={action}>
<input name="email" type="email" required />
{state.errors?.email ? <p role="alert">{state.errors.email[0]}</p> : null}
<textarea name="message" required />
{state.errors?.message ? <p role="alert">{state.errors.message[0]}</p> : null}
<button disabled={pending}>{pending ? 'Senden' : 'Absenden'}</button>
</form>
);
}Dieses Formular sendet schon vor der Hydration, weil es ein echtes Formular ist, das an einen echten Endpunkt postet. Progressive Enhancement ist hier keine Zusatzarbeit - es ist das, was man bekommt, wenn man der Plattform nicht in die Quere kommt.
Drittens: ändern, ohne zu invalidieren
Der Schreibvorgang gelingt, die Seite zeigt weiter die alten Daten, und jemand meldet einen Caching-Fehler. Es ist kein Caching-Fehler. Niemand hat dem Cache Bescheid gesagt.
revalidatePath('/posts'); // Cache dieser Route, Server und Client
revalidateTag('posts'); // jeder mit 'posts' getaggte FetchRufen Sie eines davon in der Action auf, nach dem Schreiben.
revalidatePath leert den Server-Cache dieser Route und markiert den
Router Cache im Client als veraltet - deshalb braucht eine Mutation in einer
Action sonst nichts, während dieselbe Mutation in einem handgeschriebenen
Route Handler an der Aufrufstelle router.refresh() braucht. Welcher Cache
welcher ist, lohnt sich richtig zu wissen:
die vier Ebenen und welche Sie erwischt hat.
Wann man keine nimmt
Actions sind für Mutationen, die die Anwendung selbst besitzt. Das falsche Werkzeug sind sie für:
- Alles, was ein Dritter aufruft. Webhooks brauchen eine stabile URL und eine Signaturprüfung. Das ist ein Route Handler.
- Daten lesen. Eine Action ist ein POST. Darin zu lesen kostet den Cache und bringt nichts; holen Sie die Daten auf dem Server in der Komponente.
- Datei-Uploads jeder Größe. Actions laufen durch die Server-Funktion, mit deren Body-Limit und Laufzeit. Presignen und direkt in den Storage laden.
- Alles, was Sie pro IP begrenzen wollen, bevor es Ihren Code erreicht. Middleware und ein Route Handler geben Ihnen dafür einen Ort.
Was wir prüfen, bevor eine Action ausgeliefert wird
Vier Zeilen, und sie fangen fast alles:
- Beginnt sie mit dem Session-Lookup?
- Parst sie ihre Eingabe mit einem Schema und gibt Fehler zurück, statt zu werfen?
- Invalidiert sie, was sie geändert hat?
- Steht die Eigentümerprüfung in der Query statt als Lesen mit anschließendem Vergleich?
Nichts davon ist exotisch. Es ist dieselbe Disziplin, die jeder HTTP-Endpunkt braucht - und genau das ist der Punkt, denn genau das ist eine Server Action.
