Beliebte Suchanfragen

Cloud Native

DevOps

IT-Security

Agile Methoden

Java

|
//

Server Actions in Next.js 14

10.6.2024 | 8 Minuten Lesezeit

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.

|

Beitrag teilen

Gefällt mir

0

//

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.