Understanding server actions in Next.js 14

May 27th, 202410 mins read

Understanding server actions in Next.js 14

Picture this: you're building a web application with Next.js and want to keep things as efficient and secure as possible. That's where Server Actions come into play.

These functions run on the server side, allowing you to handle tasks like data fetching and updates without exposing sensitive logic to the client. By offloading these responsibilities to the server, you streamline your code and enhance your app's security.

This guide explains how to use Server Actions in Next.js step-by-step so you can make the most of this powerful feature and easily build top-notch web applications.

What is Server Actions

In 2022, Next.js introduced client and server components, allowing developers to specify whether components should be rendered on the server or the client. This provided control over performance and user experience by leveraging the strengths of both SSR and CSR.

However, there were still challenges in efficiently managing data fetching and mutations. Developers often had to rely on various hooks and external API routes to interact with their databases, which lead to complex and sometimes cumbersome codebases.

For example, if you needed to interact with a Prisma database to manage tasks in a to-do list application, you would typically create an API route to handle the database operations:

// pages/api/tasks.js
import prisma from '../../../lib/prisma';

export default async (req, res) => {
	if (req.method === 'POST') {
		// Add a new task
		const { task } = req.body;
		const newTask = await prisma.task.create({
			data: { task },
		});

		res.status(200).json(newTask);
	}
	// other operation
};

Then, in your component, you would handle the form data and use the Fetch API or Axios to make requests to the API route:

// Client-side form handling
import { useState } from 'react';

const TaskComponent = () => {
	const [tasks, setTasks] = useState([]);
	const [task, setTask] = useState('');

	const handleSubmit = async (e) => {
		e.preventDefault();
		const res = await fetch('/api/tasks', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ task }),
		});
		const newTask = await res.json();
		setTasks([...tasks, newTask]);
		setTask('');
	};

	return (
		<div>
			<form onSubmit={handleSubmit}>
				<input
					value={task}
					onChange={(e) => setTask(e.target.value)}
					placeholder="New Task"
				/>
				<button type="submit">Add Task</button>
			</form>
		</div>
	);
};

export default TaskComponent;

While this approach is not a bad idea, it can lead to code that is more complex than necessary, especially for larger applications with numerous data interactions.

With Server Actions, you don’t need to set up a separate API route to interact with the database and then make an API request to that route. Instead, you can handle everything directly in your component.

How Sever Actions work

Server Actions, introduced in Next.js 13, simplify data handling for actions such as form submissions by allowing server-side logic to be integrated directly into components.

These actions are defined using the React use server directive. This directive can be placed at the top of an async function to mark it as a Server Action, or at the top of a file to mark all exports in that file as Server Actions.

To understand this better, let's build on the setup from a previous article on setting up Prisma and Postgres in Next.js 14. We'll learn how to mutate the database from a form.

1. Update Prisma Schema

First, update your Prisma schema to use a simple Todo model instead of the User model or just add this so we have two models:

model Todo {
    id        String  @id @default(uuid())
    title     String
    completed Boolean
}

Align your database with the new schema by running:

npx prisma migrate dev --name init

You can then seed the database or proceed directly to making UI interactions from your Next.js application.

2. Create a form in Next.js

Suppose you are creating a todo task app, and this information is stored in a Postgres database. Here’s a simple form to add new tasks from your user interface:

<form className="mb-6">
	<div className="flex flex-col mb-4">
		<label htmlFor="new-todo" className="mb-2 text-lg font-medium">
			Add a new task
		</label>
		<input
			type="text"
			id="new-todo"
			name="new-todo"
			className="p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
			placeholder="Enter your task"
		/>
	</div>
	<button
		type="submit"
		className="w-full py-2 px-4 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 disabled:opacity-50 disabled:cursor-not-allowed"
	>
		Add Task
	</button>
</form>

3. Implement Server Actions

To use Server Actions to submit the details from this form to your database, create an async function to handle this:

const addTodo = async () => {
	'use server';
	// interact with database
};

Then, add an action to the form to retrieve the form data:

<form className="mb-6" action={addTodo}>
	// form fields
</form>

4. Handle form data

You now have access the form data in the addTodo function and you can interact with your Prisma database directly from your server component:

const addTodo = async (formData: FormData) => {
	'use server';

	const title = formData.get('new-todo');
	await prisma.todo.create({
		data: {
			title: title as string,
			completed: false,
		},
	});
};

With this setup, when you click the submit button in your form, the Server Action will add the data from the form to the Prisma database.

Here's a complete example that includes fetching all tasks from the Postgres database and displaying them below the form:

import prisma from '@/lib/prisma';

const page = async () => {
	const data = await prisma.todo.findMany();

	const addTodo = async (formData: FormData) => {
		'use server';
		const title = formData.get('new-todo');
		await prisma.todo.create({
			data: {
				title: title as string,
				completed: false,
			},
		});
	};

	return (
		<div className="max-w-lg mx-auto p-6 bg-white rounded-lg shadow-md w-1/2 text-black">
			<h2 className="text-2xl font-bold mb-4">To-Do List</h2>
			<form className="mb-6" action={addTodo}>
				<div className="flex flex-col mb-4">
					<label htmlFor="new-todo" className="mb-2 text-lg font-medium">
						Add a new task
					</label>
					<input
						type="text"
						id="new-todo"
						name="new-todo"
						className="p-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
						placeholder="Enter your task"
					/>
				</div>
				<button
					type="submit"
					className="w-full py-2 px-4 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 disabled:opacity-50 disabled:cursor-not-allowed"
				>
					Add Task
				</button>
			</form>
			<div className="space-y-4">
				{data.map((todo) => (
					<div
						key={todo.id}
						className="p-4 border rounded-md flex items-center justify-between"
					>
						<span className="text-lg">{todo.title}</span>
						<button className="py-1 px-3 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50">
							Delete
						</button>
					</div>
				))}
			</div>
		</div>
	);
};

export default page;

This example showcases how Server Actions can be used to handle form submissions directly on the server, simplifying the process and reducing the complexity of your codebase:

Revalidation in Server Action

When you add new data using Server Actions, you might notice that the new data doesn't appear immediately unless you refresh the page. This issue can be resolved by revalidating the path, which ensures the latest data is displayed.

Revalidation is the process of purging the data cache and re-fetching the latest data. This is useful when your data changes and you want to ensure the most recent information is shown.

Here’s how you can implement revalidation in your Server Actions:

import prisma from '@/lib/prisma';
import { revalidatePath } from 'next/cache';

const page = async () => {
    const data = await prisma.todo.findMany();

    const addTodo = async (formData: FormData) => {
        'use server';
        const title = formData.get('new-todo');
        await prisma.todo.create({
            data: {
                title: title as string,
                completed: false,
            },
        });
        revalidatePath('/');
    };

    return (
        // ...
    );
};

In the code above, the revalidatePath function is imported from next/cache. After adding new data to the database in the addTodo function, call revalidatePath('/'). This will purge the cache for the home route (or any specified route) and re-fetch the latest data.

Add pending state with Server Action

When using Server Actions, you have access to some functions that help make interactivity possible. For example, you can use the useFormStatus hook to show a pending state while the form is being submitted. This allows you to add certain functionalities, such as disabling the submit button while the form is submitting.

However, the issue is that hooks can only be used in client components. To address this, you need to create a separate component for the form button and import it into the main page.

Create a new file called components/AddTaskBtn.tsx and define the button component:

const AddTaskBtn = () =>
    return (
        <button
            type="submit"
            className="w-full py-2 px-4 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 disabled:opacity-50 disabled:cursor-not-allowed"
        >
            Add Task
        </button>
    );
};

export default AddTaskBtn;

To use the useFormStatus hook in this component, you need to specify that it is a client component. Then, import the hook, instantiate it, and disable the button when the state is pending:

'use client';
import { useFormStatus } from 'react-dom';

const AddTaskBtn = () => {
	const { pending } = useFormStatus();

	return (
		<button
			type="submit"
			disabled={pending}
			className="w-full py-2 px-4 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 disabled:opacity-50 disabled:cursor-not-allowed"
		>
			Add Task
		</button>
	);
};

export default AddTaskBtn;

Now, you can import and use this button component in your main page where you have the form.

Use Server Action in client component

It's also important to know that you can use Server Actions in a client component. To do this, you need to extract the action function into a separate file and then import it into the client component.

This approach works with server components as well, providing a separation of concerns. You can organize all your actions in different files and only import them when needed or into multiple components.

To get started, first, create an actions folder in the root of your project. Then, create a addTodo.ts file to hold the add todo server action:

'use server';
import prisma from '@/lib/prisma';
import { revalidatePath } from 'next/cache';

export const addTodo = async (formData: FormData) => {
	const title = formData.get('new-todo');
	await prisma.todo.create({
		data: {
			title: title as string,
			completed: false,
		},
	});
	revalidatePath('/');
};

To use this action in your client component, import it and use it as before:

import { addTodo } from '@/actions/addTodo';

To explain this better, let’s create a server action that deletes an individual task. Create a deleteTodo.ts file in the actions folder and add the following code:

'use server';
import prisma from '@/lib/prisma';
import { revalidatePath } from 'next/cache';

export const deleteTodo = async (id: string) => {
	await prisma.todo.delete({
		where: {
			id: id,
		},
	});
	revalidatePath('/');
};

This code gets the task ID, deletes it from the database, and revalidates the path.

We would then import this action and attach it to an onClick event listener. But… event handlers can only be used in client components because they enable interactivity.

To use the delete action with a button, create a separate component for the button so it’s a client component. Create a components/DeleteBtn.tsx file and import the delete action:

'use client';
import { deleteTodo } from '@/actions/deleteTodo';

export const DeleteBtn = ({ todoId }: { todoId: string }) => {
	return (
		<button
			className="py-1 px-3 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"
			onClick={() => deleteTodo(todoId)}
		>
			Delete
		</button>
	);
};

Now, import the button component in your parent component and pass the ID as a prop:

<DeleteBtn todoId={todo.id} />

Conclusion

There is much more to explore with Server Actions, including data validations, error handling and use of some advanced functions like useFormState, useOptimistic, and handling complex form logic.

Checkout the Next.js official documentation for more information.