Next.js Server Actions are functions that execute on the server side. Having these special functions that only run on the server means that developers can offload responsibilities like data fetching and mutations to them, avoiding the vulnerabilities and security concerns of fetching and mutating data from the client.
To fully understand Server Actions, it is important to understand the problems they address, what we did prior to them, their evolution, and their evolution. We'll delve into all of these aspects as we progress through this post. But first, let's start from the beginning.
The history of Server Actions
Let’s take a little trip down memory lane.
The journey began with server-side rendering (SSR) in PHP. Things like data fetching, mutations, and CPU-intensive tasks happened on the server so that the browser only got a lightweight, rendered page to show users:
However, this meant that for every user navigation, we needed to perform that round trip again: send a request to the server, generate that page, and send it to the client. Each time, the user had to wait for that process to complete before they could see and interact with the page — we didn’t like that.
Enter the next wave of innovation: client-side rendering (CSR). Instead of sending a new request to the server every time a user navigation happens, what if we made the client handle that navigation?
That would mean that the first time the server responds, it will send the rendering code to the client. This enables the client to handle the page rendering as the user navigates through the site:
With CSR, we solved the problem of round trips, achieving faster and more responsive page transitions. But we also introduced a new problem for ourselves.
When the server sends JavaScript to the browser, search engines aren’t able to index the site properly because the actual HTML of the site is not fully formed yet. The browser will have to first download and execute the JavaScript from the server and then hydrate the page to form the complete markup of the site.
This is where static site generation (SSG) came in. It was the latest innovation in achieving faster page loads with SEO-ready markup. The idea is to combine the best features of CSR and SSR to create the best of both worlds. We thought, why not pre-generate all the site’s pages on the server at build time?
A build script or static site generator tool processes the source code and content to generate static HTML files for each page on the website:
The build process may also involve fetching data from various sources, such as APIs or databases, and rendering static HTML pages. However, there were concerns about how it handles dynamic content updates and real-time data.
Having no clear path for those kinds of scenarios suggested that SSG may not be ideal for all kinds of websites. As a result, we now make our way back to SSR with React Server Actions.
React Server Components and Actions
With the journey we’ve been on from SSR to CSR to SSG, and everything in between, one thing became certain: there’s no one-size-fits-all solution. So React Server Components (RSCs) came up to give developers the ability to separate concerns.
With Server Actions, you can now say something like: “XYZ components should execute ONLY on the server, while ABC components should execute on the client.” This functionality is made possible by React Actions, which is the concept the Next.js Server Actions are built upon:
Server Actions were introduced in Next.js v14 as a way to write functions that are earmarked to execute either on the server or on the client. This is particularly helpful in the areas of data fetching and mutations.
The combination of RSCs and Server Actions in Next.js meant that we had a better way of thinking about data fetching. Previously, we fetched data in many different ways.
For instance, you could use the useEffect
Hook to fetch data and manage loading states. You could also do page-level data fetching with GetStaticProps
and GetServerSideProps
. And when you need to make database calls with sensitive credentials, you set up an API route and define your functions there.
Now, with RSCs and Server Actions, it’s much easier to make sense of things. Every component can fetch its own data and mutations can happen right within the component that does it, alleviating the need for external API routes. Let’s see Server Actions in action (pun intended).
Getting started with Next.js Server Actions
We’ll build a simple to-do project to show how we did things in the past and how we do things now, with Server Actions. This project will accept user input and update a MongoDB database.
Prerequisites:
- Node.js ≥v18 installed
- A Supabase database account to store to-dos (feel free to use any other DB of your choice)
Setting up your Next.js project
To begin, we will create a new Next.js 14 app by running the following command:
npx create-next-app@latest server-action
Accept all the default prompts by pressing the Enter key until completion. When the project is set up, navigate into the project directory and run the development server:
cd server-action && yarn dev
Now the development server should be running on https://localhost:300.
Lastly, install the Supabase JavaScript package into the project if you will use Supabase for your database needs:
npm install @supabase/supabase-js
Building the form element
Here, we will create a form to handle user inputs — in this case, the user’s to-dos. To do this, add the following snippet into the app/page.tsx
file:
// app/page.tsx
export default function TodoList() {
return (
<>
<h2>Server Actions Demo</h2>
<div>
<form action="#" method="POST">
<div>
<label htmlFor="todo">Todo</label>
<div>
<input id="todo" name="text" type="text"
placeholder="What needs to be done?"
required
/>
</div>
</div>
<div>
<button type="submit"> Add Todo</button>
</div>
</form>
</div>
</>
);
}
I have excluded the Tailwind classes on the snippet above to avoid clutter.
With that, we should now get a barebones form that we can start messing with:
How we did things before Server Actions
In the past, when we wanted to handle data mutations in Next.js, we set up an API route like pages/api/form.js
. In there, we define a function that receives our form submission and stores it in the database. The file will typically look like this:
// pages/api/form.js
export default async function handler(req, res) {
if (req.method === 'POST') {
const { todoItem } = req.body;
if (!todoItem) {
// fail fast
}
try {
// save todo item to database
} catch (error) {
// handle error
}
} else {
// handle req.methong != POST
}
}
We’d then need to set up the client to post the form data (to-do item) to that route when a user submits a to-do item:
// pages/index.tsx
import { useState } from 'react';
export default function Home() {
const [todoItem, setTodoItem] = useState('');
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
// Send a POST request to the API route with the todo item
const response = await fetch('/api/form', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ todoItem }),
});
if (response.ok) {
console.log('Todo item added successfully');
} else {
console.error('Failed to add todo item');
}
} catch (error) {
console.error('Error:', error);
}
};
return (
<div>
<h1>Todo Application</h1>
<form onSubmit={handleSubmit}>
<label>
Todo Item:
<input
type="text"
value={todoItem}
onChange={(e) => setTodoItem(e.target.value)}
/>
</label>
<button type="submit">Add Todo</button>
</form>
</div>
);
}
This would successfully post the form data to our api/form
route, which would in turn post that data to our database. Practically, we also need to update the UI to show the to-do item that just got added.
So the following operations need to happen for this form submission process to be completed:
- User submits form
- Form data gets posted to the
api/form
route - API route receives form data and saves it to the database
- UI updates to fetch new data from the database
That was how we handled form submissions in the past, but this process was simplified with the introduction of Server Actions.
How we do things now with Server Actions
As I mentioned earlier, Server Actions allow us to create functions that can only run on the server, which means that we can handle database mutations right inside our components. To post the form data to our database using Server Actions, let’s update the app/page.tsx
file like so:
// app/page.tsx
import { createClient } from '@supabase/supabase-js';
export default function TodoList() {
const addTodo = async (formData: FormData) => {
'use server';
const supabaseUrl = 'YOUR_SUPABASE_URL';
const supabaseKey = process.env.SUPABASE_KEY;
const supabase = createClient( supabaseUrl, supabaseKey);
const todoItem = formData.get('todo');
if (!todoItem) {
return;
}
// Save todo item to database
const { data, error } = await supabase.from('todos').insert({
todo: todoItem,
});
};
return (
<>
<h2>Server Actions Demo</h2>
<div>
<form action={addTodo} method="POST">
<div>
<label htmlFor="todo">Todo</label>
<div>
<input id="todo" name="text" type="text"
placeholder="What needs to be done?"
required
/>
</div>
</div>
<div>
<button type="submit"> Add Todo</button>
</div>
</form>
</div>
</>
);
}
That’s it. We updated the action
attribute on the form to call the addTodo
function. The addTodo
function is a Server Action that receives a formData
prop for easy access to the form values. The 'use server'
directive specifies that this function should only execute on the server. As such, we can perform database mutations in it as we just did.
At this point, if a user clicks the AddTodo
button, sure enough, we can see that the provided to-do item gets added to the database: You may notice from the GIF above that the database updates with the form data as expected, but the UI doesn’t update. Let’s fix that by fetching the to-dos and listing them on the page.
First, let’s fetch the to-do data from the database by updating the app/page.tsx
file like so:
// app/page.tsx
import { createClient } from '@supabase/supabase-js';
export default function TodoList() {
+ const supabaseUrl = 'YOUR_SUPABASE_URL';
+ const supabaseKey = process.env.SUPABASE_KEY;
+ const supabase = createClient( supabaseUrl, supabaseKey);
+ const { data, error } = await supabase.from('todos').select('todo');
const addTodo = async (formData: FormData) => {
'use server';
const supabaseUrl = 'YOUR_SUPABASE_URL';
const supabaseKey = process.env.SUPABASE_KEY;
const supabase = createClient( supabaseUrl, supabaseKey);
// add todo to DB
};
return (
<>
<h2>Server Actions Demo</h2>
<div>
<form action={addTodo} method="POST">
<div>
<label htmlFor="todo">Todo</label>
<div>
<input id="todo" name="text" type="text"
placeholder="What needs to be done?"
required
/>
</div>
</div>
<div>
<button type="submit"> Add Todo</button>
</div>
+ <ul>
+ {data &&
+ data.map((todo: any) => (
+ <li
+ key={todo._id} >
+ <span>{todo.todo}</span>
+ </li>
+ ))}
+ </ul>
</form>
</div>
</>
);
With this new addition, we should now see a list of all the to-do items we’ve added to the database: However, you may notice that we initialized the database twice in that file. First inside the parent TodoList()
to fetch to-dos on the client, and second inside the addTodos
Server Action where we post to-do items to the database.
This is because we can’t use variables and props initialized in the client in a Server Action. So, in simpler terms, you can’t define and use Server Actions directly inside a client component. So how should we use Server Actions in client components?
How to use Server Actions in client components
To use the addTodos
Server Action properly in our client component, we need to extract it into a different file, and then import it into the client component. This is a good way to enforce separation of concerns as it ensures that every piece of code the Action needs is contained within the Action itself and doesn’t get mixed with client-side code.
To solve this, move the Server Action into an actions
directory and define it there. Create a app/src/actions/addTodo.ts
file and update it like this:
'use server';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'YOUR_SPB_URL';
const supabaseKey = process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey ?? '');
export default async function addTodo(formData: FormData) {
'use server';
const todoItem = formData.get('todo');
if (!todoItem) {
return;
}
// Save todo item to supabase database
const { data, error } = await supabase.from('todos').insert({
todo: todoItem,
created_at: new Date().toISOString(),
});
}
Here, we’ve extracted the addTodo
Server Action from the client component into its own directory. To use it again in the client, we can simply import it into the component like so:
import addTodo from '@/actions/addTodo';
This pattern also allows us to define multiple Server Actions in the same file and import them where they are needed without mixing it up with client-side logic. Now that we’ve organized the codebase into a more composable architecture, let’s continue with the demo.
UI updates
So far, we’ve been able to POST to-dos to our database using the Server Action and also fetch the to-dos data from the database and display for users. However, if a user adds a new to-do item, the UI won’t update — even with a page reload. This is due to the way Next.js caches requests.
To fix that, we need to revalidate the path we are on when we submit the form to burst the cache and fetch the latest data after the form submission. So update the Server Action like this:
// src/app/actions/addTodo.ts
'use server';
+ import { revalidatePath } from 'next/cache';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = 'https://spzrankpwrdffeakqkbi.supabase.co';
const supabaseKey = process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey ?? '');
export default async function addTodo(formData: FormData) {
'use server';
const todoItem = formData.get('todo');
if (!todoItem) {
return;
}
// Save todo item to supabase database
const { data, error } = await supabase.from('todos').insert({
todo: todoItem,
created_at: new Date().toISOString(),
});
+ revalidatePath('/');
}
With the revalidation piece in place, adding a new to-do item will now also update the UI with the latest item in the database: With this, we’ve completed our working demo of a to-do application powered by Server Actions in Next.js. You can extend this demo further by extracting the to-do functionality into a component and then render the component in the homepage in line with the component based architecture of React.
You can also optimistically update the UI to achieve a faster response time and a better user experience using the React useOptimistic
Hook.
Lastly, if you want to go all the way, you can use the React useTransition
API to show loading states for the Add Todo
button when a user submits a new to-do item. If you’d be interested in seeing another demo or post where all these functionalities are implemented, let me know in the comments. In the meantime, let’s look at some gotchas.
Server Actions gotchas
As much as Server Actions are a good addition to Next.js 14, there are still some valid concerns that you need to be aware of before adopting it:
- Different mental model: Server Actions introduce a different way of fetching data in Next.js applications. If you’ve been working with Next.js for some time and stumble on a Next.js 14 codebase with Server Actions, it can feel a bit unusual and may introduce a steep learning curve
- Separation of concerns is difficult at first: With Server Actions and RSCs, you are forced to separate client-side logic from server-side logic. This has obvious benefits but can be tricky to figure out at first. Previously, you could get away with simply writing your code, and the browser and Next.js would handle the rendering patterns for you. Now, you have to specify where components should execute, which ones are client components, which ones are server, and how they work together
- Database mutations right inside your markup: Many people frowned on Server Actions particularly because of this pattern. It was similar to how PHP worked back in the day, which threw a lot of people off. However, if you’re comfortable with this way of writing your logic, you should be fine after some practice with it
Conclusion
In this post, we reviewed the history that led to Server Actions and how the rendering pattern has evolved over time on the web. To practically demonstrate Server Actions and its benefits, we built a demo that showed how we handle functionalities like form submission prior to Server Actions and the improvements that Server Actions introduced.
We also looked at how to use Server Actions in client components and how to revalidate the cache to fetch new content. I hope all of this helps you make more informed decisions about this feature and about Next.js in general as you work with it.
LogRocket: Full visibility into production Next.js apps
Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your Next.js apps — start monitoring for free.
Top comments (1)
Thanks for the example, its well written, haven't had a chance to test these server actions, but it does seam like writing db queries or updates in the markup is a bad idea, and but there are some ways to separate the markup from the server logic which is good, btw. I wonder how does it actually work under the hood, some network request is sent like an api its just dynamically generated :/