Server Actions wurden in Next.js 14 als neue Methode zum Senden von Daten an den Server eingeführt (siehe die Dokumentation). Es sind asynchrone Funktionen, die sowohl in Server-Komponenten, innerhalb von serverseitigen Forms, als auch in Client-Komponenten verwendet werden können. Während der Aufruf einer Server Action im Code als ein normaler Funktionsaufruf erscheint, wird er von Next.js als POST-Request an den Server interpretiert.
In diesem Blogpost zeige ich anhand einfacher Beispiele, wie wir Server Actions verwenden können und was dabei zu beachten ist.
Server Actions: ein Beispiel
Als Beispiel dient eine einfache Webanwendung, mit der User angelegt und gesucht werden können. Die Suche soll live anhand eines Suchtexts erfolgen, d.h. unsere Anwendung muss clientseitig auf Änderungen an der Sucheingabe reagieren und die Daten vom Server laden.
Im Folgenden gehe ich nur auf die wichtigen Code-Stellen ein. Den gesamten Code der Beispielanwendung findet ihr hier: https://github.com/LukasL97/nextjs-server-actions-example
User anlegen und bearbeiten
Zum Anlegen und Bearbeiten von Usern definieren wir eine UserPage
.
Diese wird zunächst serverseitig gerendert und lädt dabei den existierenden User direkt aus der
Datenbank, falls im URL-Pfad eine ID übergeben wurde. Dieser User wird dann an die UserForm
-Komponente weitergegeben,
mit der der User bearbeitet werden kann, bzw. neu angelegt, falls keine ID übergeben wurde.
1// app/user/[[...id]]/page.tsx
2
3import { getUserFromDb } from '@/app/db';
4import { UserForm } from '@/app/components/UserForm';
5
6export default async function UserPage({ params }: { params: { id?: string[] } }) {
7 const id = params.id?.at(0);
8 const user = id ? await getUserFromDb(id) : undefined;
9
10 return <>
11 <h1>User</h1>
12 <UserForm user={user}/>
13 </>;
14}
Die Komponente UserForm
ist wiederum eine Client-Komponente.* Sie enthält den firstName
und lastName
als States,
die
gesetzt werden, wenn wir in die entsprechenden Inputs schreiben. Beim Klick auf den Save-Button wird die Server
Action saveUser
aufgerufen, die den firstName
, lastName
und ggf. noch die id
, falls diese existiert, übergeben
bekommt. Daraufhin wird zurück zur Root URL navigiert.
1// app/components/UserForm.tsx
2
3'use client';
4
5import { useRouter } from 'next/navigation';
6import { saveUser } from '@/app/actions/saveUser';
7import { useState } from 'react';
8import { User } from '@/app/user';
9
10export function UserForm({ user }: { user: User | undefined }) {
11 const id = user?.id;
12
13 const [firstName, setFirstName] = useState(user?.firstName ?? '');
14 const [lastName, setLastName] = useState(user?.lastName ?? '');
15
16 const router = useRouter();
17
18 return <>
19 <div>
20 <label>First Name</label>
21 <input
22 type="text"
23 value={firstName}
24 onChange={(e) => setFirstName(e.target.value)}
25 />
26 </div>
27 <div>
28 <label>Last Name</label>
29 <input
30 type="text"
31 value={lastName}
32 onChange={(e) => setLastName(e.target.value)}
33 />
34 </div>
35 <button onClick={async () => {
36 await saveUser({ id, firstName, lastName });
37 router.push('/');
38 }}>
39 Save
40 </button>
41 </>;
42}
Die Server Action saveUser
ist eine einfache asynchrone Funktion. Wichtig ist, dass sie mit 'use server'
deklariert
wird, damit Next.js sie als Server Action identifizieren kann.
Die Server Action schreibt den übergebenen User in die Datenbank. Falls er noch keine ID hat (also ein neu
angelegter User ist), wird zunächst noch eine zufällige ID erzeugt. Schließlich wird noch der Cache
mittels revalidatePath('/')
invalidiert. Dadurch stellen wir sicher, dass die Root Page beim nächsten Aufruf frisch
gerendert wird und die aktuellen User enthält.
1// app/actions/saveUser.ts 2 3'use server'; 4 5import { User } from '@/app/user'; 6import { randomUUID } from 'crypto'; 7import { putUserIntoDb } from '@/app/db'; 8import { revalidatePath } from 'next/cache'; 9 10export async function saveUser(user: User) { 11 if (!user.id) { 12 user.id = randomUUID(); 13 } 14 await putUserIntoDb(user); 15 revalidatePath('/'); 16}
Das Beispiel mit saveUser
zeigt uns, wie wir Server Actions verwenden können, um Daten an den Server zu senden.
Im Vergleich zu Route Handlers haben
Server Actions den Vorteil, dass sie Typsicherheit zur Compile-Zeit sicherstellen.
Während es bei Route Handlers z.B. schnell passieren kann, dass unser Request Body auf der Client-Seite nicht mit dem
erwarteten Body auf der Server-Seite übereinstimmt, wird das durch die statische Typisierung bei Server Actions zunächst
verhindert.
Wichtig zu erwähnen ist allerdings, dass Server Actions natürlich, genauso wie Route Handlers, unter der Haube HTTP-Endpunkte sind. Das heißt, dass ein Nutzer, der über das Frontend auf die Anwendung Zugriff hat, auch Zugriff auf den POST-Request hat, der bei Aufruf der Server Action an den Server gesendet wird, sowie auf die Response, die er vom Server empfängt. Dadurch ist er z.B. in der Lage, einen fehlerhaften Request Body an den Server zu schicken. Besonders bei öffentlich verfügbaren Anwendungen ist daher in der Praxis doch eine Eingabevalidierung auf Server-Seite notwendig, da auch TypeScripts statische Typisierung solche Situationen nicht abfangen kann. Genauso müssen natürlich Authentifizierung und Authorisierung in der Server Action geprüft werden.
Suche von Usern
Wir haben gesehen, dass wir Server Actions verwenden können, um Daten an den Server zu senden. Dadurch drängt sich natürlich die Frage auf, ob wir Server Actions auch verwenden können, um Daten vom Server zu lesen. Tatsächlich ist das technisch möglich, da Server Actions auch eine Response an den Client zurückgeben können. Ich möchte zunächst zeigen, wie wir eine Live-Suche mittels einer Server Action implementieren können und danach erklären, warum das in der Praxis keine so gute Idee ist und wie man es besser macht.
Die SearchPage
ist als serverseitige Komponente implementiert und lädt initial alle User direkt aus der Datenbank. Da
wir hier serverseitiges Rendering verwenden, müssen wir zunächst einmal weder auf Server Actions noch auf Route Handlers
zurückgreifen, um uns die User vom Server zu ziehen.
1// app/page.tsx
2
3import { UserSearch } from '@/app/components/UserSearch';
4import { getAllUsersFromDb } from '@/app/db';
5
6export default async function SearchPage() {
7 const users = await getAllUsersFromDb();
8
9 return <>
10 <h1>User Search</h1>
11 <UserSearch users={users}/>
12 </>;
13}
Die SearchPage
gibt die initial geladenen User an die UserSearch
-Komponente weiter, die clientseitig gerendert wird.
Die UserSearch
hält die Liste der User als State. Über ein Input-Feld kann nach Usern gesucht werden. Sobald sich
der Input ändert, wird die Server Action getUsers
aufgerufen, und das Ergebnis mittels setUsers
in den State
geschrieben.
Unterhalb des Suchfelds wird die aktuelle Userliste angezeigt.
1// app/components/UserSearch.tsx
2
3'use client';
4
5import { User } from '@/app/user';
6import { useState } from 'react';
7import { getUsers } from '@/app/actions/getUsers';
8import Link from 'next/link';
9
10export function UserSearch(props: { users: User[] }) {
11 const [users, setUsers] = useState(props.users);
12
13 return <>
14 <input
15 type="text"
16 placeholder="Search user..."
17 onChange={async (e) => {
18 const users = await getUsers(e.target.value);
19 setUsers(users);
20 }}
21 />
22 <ul>
23 {users.map((user, index) => (
24 <li key={index}>
25 <Link href={`/user/${user.id}`}>{user.firstName} {user.lastName}</Link>
26 </li>
27 ))}
28 </ul>
29 <Link href="/user">
30 <button>New User</button>
31 </Link>
32 </>;
33}
Die Server Action getUsers
ist eine asynchrone Funktion, die mit 'use server'
als Server Action deklariert wird und
ein Promise<User[]>
zurückgibt.
getUsers
ruft zunächst alle User von der Datenbank ab und filtert dann anhand des gegeben Suchbegriffs, sodass nur
diejenigen User zurückgegeben werden, deren firstName
oder lastName
den Suchbegriff enthält.
1// app/actions/getUsers.ts
2
3'use server';
4
5import { User } from '@/app/user';
6import { getAllUsersFromDb } from '@/app/db';
7
8export async function getUsers(searchTerm: string): Promise<User[]> {
9 const users = await getAllUsersFromDb();
10 return users.filter(user => user.firstName.includes(searchTerm) || user.lastName.includes(searchTerm));
11}
Wie man es ohne Server Action besser macht
Wir haben gesehen, dass wir mit der Server Action getUsers
die Liste von Usern vom Server laden können, wenn sich
clientseitig etwas an der Sucheingabe ändert.
Der Ansatz hat jedoch einen entscheidenden Nachteil: die Server Action wird als POST-Request umgesetzt.
Da wir Daten vom Server lesen, würden wir eigentlich einen GET-Request erwarten.
Praktisch hat die Limitierung auf POST-Requests den Nachteil, dass Requests nicht gecached werden.
Das bedeutet, dass bei jeder Abfrage von getUsers
mit dem gleichen Suchbegriff der Server tatsächlich aufgerufen wird,
statt dass bei wiederholten Abfragen die bestehenden Daten aus dem Cache geladen werden.
Glücklicherweise gibt es einen einfachen Weg, wie wir die Live-Suche auch ohne Server Action (und auch ohne Route
Handler) umsetzen können, nämlich mittels eines Suchparameters, den wir der SearchPage
übergeben.
Die SearchPage
verwendet serverseitiges Rendering und lädt die User direkt aus der Datenbank.
Dann filtert sie die Liste von Usern anhand des searchTerm
, der über den Suchparameter q
in der URL übergeben wird.
1// app/page.tsx
2
3import { UserSearch } from '@/app/components/UserSearch';
4import { getAllUsersFromDb } from '@/app/db';
5
6export default async function SearchPage({ searchParams }: {
7 searchParams: { [key: string]: string | string[] | undefined }
8}) {
9 const searchTerm = getSearchTerm(searchParams);
10
11 const users = (await getAllUsersFromDb())
12 .filter(user => user.firstName.includes(searchTerm) || user.lastName.includes(searchTerm));
13
14 return <>
15 <h1>User Search</h1>
16 <UserSearch users={users}/>
17 </>;
18}
19
20function getSearchTerm(searchParams: { [key: string]: string | string[] | undefined }): string {
21 const searchTerm = searchParams?.q;
22 if (typeof searchTerm !== 'string') return '';
23 return searchTerm;
24}
Die Komponente UserSearch
ändert sich insofern, als dass der onChange
-Handler des Input-Felds nicht mehr die Server
Action getUsers
aufruft.
Stattdessen wird der Router verwendet, um den eingegeben Suchbegriff als Suchparameter q
an die URL zu hängen.
1'use client';
2
3import { User } from '@/app/user';
4import Link from 'next/link';
5import { useRouter } from 'next/navigation';
6
7export function UserSearch(props: { users: User[] }) {
8 const router = useRouter();
9
10 return <>
11 <input
12 type="text"
13 placeholder="Search user..."
14 onChange={async (e) => {
15 router.push(`?q=${e.target.value}`);
16 }}
17 />
18 <ul>
19 {props.users.map((user, index) => (
20 <li key={index}>
21 <Link href={`/user/${user.id}`}>{user.firstName} {user.lastName}</Link>
22 </li>
23 ))}
24 </ul>
25 <Link href="/user">
26 <button>New User</button>
27 </Link>
28 </>;
29}
Bei jedem Zeichen, welches der Nutzer in das Suchfeld eingibt, wird nun ein Fetch Request mit der GET-Methode und dem neuen Suchbegriff als Parameter ausgeführt. Der Fetch Request hat den Vorteil, dass nicht die komplette Seite neu geladen wird, sondern nur die Daten auf der Seite, was eine angenehmere User Experience bedeutet. Außerdem wird der clientseitige Router Cache von Next.js verwendet. Dieser verhindert, dass bei Eingabe des gleichen Suchbegriffs kurz hintereinander der gleiche Fetch Request mehrfach aufgerufen wird, und reduziert dadurch die Anzahl an Requests, die tatsächlich an den Server gesendet werden.
Fazit
Server Actions in Next.js 14 bieten eine interessante Option, Daten serverseitig zu verarbeiten und gleichzeitig statische Typesicherheit zu gewährleisten. Wir haben anhand des Beispiels gesehen, wie wir eine Webanwendung mit Datenbankanbindung in Next.js komplett ohne Route Handlers umsetzen können. Im Code erscheinen die Server Actions als normale Funktionsaufrufe, was zu einer guten Lesbarkeit des Codes beitragen kann.
Wir haben aber auch gelernt, dass Server Actions nicht das Allheilmittel sind, mit dem wir die gesamte Kommunikation zwischen Client und Server abbilden sollten, auch wenn das technisch möglich ist. Zum Lesen von Daten ist serverseitiges Rendering die bessere Option, da wir dadurch Caching-Mechanismen optimal ausnutzen können. Außerdem müssen wir auch bei der Verwendung von Server Actions Maßnahmen wie Authorisierung und Validierung ergreifen, die eine sichere und korrekte Ausführung unserer Anwendungen gewährleisten.
* In dem konkreten Beispiel wäre es zwar auch möglich UserForm
als Server-Komponente zu implementieren, bei
der saveUser
als Submit Action verwendet wird. In der Praxis ist es allerdings häufig so, dass wir clientseitige
Validierungen verwenden wollen, welche direkt bei Eingabe des Nutzers eventuelle Fehler anzeigen und daher nur mit
clientseitigem Rendering funktionieren.
Weitere Beiträge
von Lukas Lehmann
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Lukas Lehmann
Backend Developer
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.