Beliebte Suchanfragen

Cloud Native

DevOps

IT-Security

Agile Methoden

Java

|
//

Server Actions in Next.js 14

10.6.2024 | 9 minutes of reading time

Server Actions were introduced in Next.js 14 as a new method to send data to the server (see the documentation). They are asynchronous functions that can be used in server components, within server-side forms, as well as in client-side components. While the invocation of a Server Action appears as a normal function call in the code, it is interpreted as POST request to the server by Next.js.

In this blog post, I demonstrate in simple examples how Server Actions can be used and what we have to consider when using them.

Server Actions: an example

As an example, we implement a simple web application that can be used to save users and also search them. The search should use a search text and react to client-side changes in the input by loading the data from the server.

In the following, I will only show the important code sections. You can find the entire source code of the example application here: https://github.com/LukasL97/nextjs-server-actions-example

Adding and editing users

We define a UserPage to add and edit users. The page is rendered server-side and loads the existing user directly from the database if an ID was given via the URL path. This user is then passed to the UserForm component, which can be used to edit the user, respectively add a new user if we did not pass any ID.

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}

The UserForm component is implemented as a client component.* It contains the firstName and lastName as states, which are set when we write into the respective inputs. On clicking the save button, the Server Action saveUser is called, passing it the firstName, lastName, and possibly the id, if it exists. After that, we route back to the root URL.

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}

The Server Action saveUser is a simple asynchronous function. It has to be noted that we have to declare it with 'use server', in order to ensure that Next.js is able to identify it as a Server Action. The Server Action writes the given user into the database. If it does not yet have an ID (i.e., if it is a newly added user), a random ID is generated first. Finally, the cache is invalidated using revalidatePath('/'). Thereby, we make sure that the root page will be freshly rendered on the next load, containing the updated users.

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}

The saveUser example shows us how we can use Server Actions to send data to the server. Compared to Route Handlers, Server Actions have the advantage that they provide type safety during compile time. When using Route Handlers, it can easily happen that, for instance, our request body on the client side does not match the expected request body on the server side. The static type checking on Server Actions prevents this error.

However, it is important to note that Server Actions, just as Route Handlers, are implemented as HTTP endpoints under the hood. This means that if a user has access to the frontend of the application, they will also have access to the POST request, which is sent to the server when the Server Action is invoked, as well as the response that is received from the server. A user could use this information to send an invalid request body to the server. Hence, we still require an input validation in practice, especially for publicly available applications, as TypeScript's static typing will not prevent such scenarios. Furthermore, the Server Action needs to check for authentication and authorization as well.

User search

We have seen that we can use Server Actions to send data to the server. This raises the question if we can use Server Actions to read data from the server as well. In fact, this is technically possible, as Server Actions are able to return a response to the client. I would first like to demonstrate how we can implement a live search using a Server Action and then explain why this is not that great of an idea in practice, and provide a better alternative.

The SearchPage is implemented as a server-side component that initially loads all users directly from the database. As we make use of server-side rendering here, we require neither Server Actions nor Route Handlers to fetch the users from the server at this point.

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}

The SearchPage passes the initially loaded users to the UserSearch component, which is rendered on the client side. The UserSearch holds the list of users as state. Via an input field, one can search for users. As soon as the input is changed, the Server Action getUsers is invoked and the result is written to the state via setUsers. Below the search field, the current list of users is printed.

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}

The Server Action getUsers is an asynchronous function, declared as Server Action via 'use server', returning a Promise<User[]> as result. First, getUsers loads all users from the database. Then, it filters the users using the given searchTerm, returning only the users whose firstName or lastName contain the searchTerm.

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}

A better alternative without Server Actions

We have seen that we can load the list of users from the server using the Server Action getUsers whenever a client-side change on the search input occurs. However, this approach has a decisive drawback: the Server Action is implemented as POST request. As we are reading data from the server, we would actually expect a GET request instead. In practice, this limitation to POST requests has the disadvantage that requests are not cached. This means that on each call of getUsers with the same search term the server is actually invoked, instead of loading existing data from the cache on recurring invocations.

Fortunately, there is a simple way to implement live search without a Server Action (and without a Route Handler as well), namely using a search parameter that we pass to the SearchPage. The SearchPage uses server-side rendering and loads the users directly from the database. Then, the list of users is filtered using the searchTerm, which is given via the search parameter q in the URL.

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}

The UserSearch component is changed with regard to the onChange handler of the input field, which no longer calls the Server Action getUsers. Instead, the router is used to add the input search term as search parameter q to the URL.

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}

On every character that the user enters into the search field, a fetch request is executed, using the GET method and the new search term as parameter. A fetch request has the advantage that it does not lead to a full page reload, but instead only loads the data on the page, which leads to a better user experience. Furthermore, it uses the client-side Router Cache of Next.js. The Router Cache prevents the same fetch request from being executed multiple times when we enter the same search term several times in a row. In this way, we are able to reduce the number of requests actually sent to the server.

Conclusion

Server Actions in Next.js 14 offer an interesting option to process data on the server while ensuring static type safety. Based on an example, we have seen how we can implement a web application with database access in Next.js completely without using Route Handlers. In the code, Server Actions appear as normal function invocations, which may improve readability of the code.

However, we also learned that Server Actions are not a one-size-fits-all solution to implement the entire communication between client and server, even though we can technically use them in that way. For reading data, server-side rendering is a better option, as it allows us to make optimal use of caching mechanisms. Furthermore, when using Server Actions, we must also take measures such as authorization and validation to ensure that our applications run securely and correctly.


* In this concrete example, it would indeed be possible to implement UserForm as a server component, which uses saveUser as a submit action. In practice however, we often would like to make use of client-side validation, which shows potential errors directly on a user input and therefore can only work with client-side rendering.

|

share post

Likes

0

//

More articles in this subject area

Discover exciting further topics and let the codecentric world inspire you.

//

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.