Next.js is a React framework that enables static site generation (SSG), server-side rendering (SSR), and incremental static regeneration (ISR). It is known for its performance, scalability, and ease of use.
One of the most exciting new features in Next.js 14 is Server Actions. Server Actions allow you to execute server-side code without creating dedicated API routes. Server Actions can be helpful in various tasks, such as fetching data from external APIs, performing business logic, or even updating your database.
Understanding Next.js Server Actions
Server Actions have been a part of Next.js since version 13. However, in version 14 of the framework, Server Actions are stable and incorporated by default as a framework feature.
You can use Server Actions for a variety of use cases, but they are well-suited for the following ones:
- Fetching data from external APIs: Server Actions can fetch data from external APIs without compromising performance or security.
- Performing business logic: Server Actions can perform business logic that requires server execution, such as validating user input or processing payments.
- Updating your database: Server Actions can update your database without creating separate API routes.
How Server Actions work
Next.js Server Actions are asynchronous JavaScript functions that run on the server in response to user interactions on the client. Server Actions are made possible by a number of complex technical processes, but the basic idea is as follows:
- In the client, a user action or business logic condition triggers a function call to a Server Action. For the developer, this is no different than calling an async function.
- Next.js will serialize the request parameters (such as any form data or query string parameters) and send them to the server.
- The server will then deserialize the request parameters and execute a function that represents the Server Action.
- Once the Server Action has finished executing, the server will serialize the response and send it back to Next.js. Next.js will then deserialize the response and send it back to the front end.
- When the promise resolves, the front end will continue its client-side execution.
The syntax for defining Server Actions
You can define Server Actions in two places:
- In the server component that uses it.
// app/page.ts
export default function ServerComponent() {
async function myAction() {
'use server'
// ...
}
}
- Or in a separate file for reusability.
// app/actions.ts
'use server'
export async function myAction() {
// ...
}
How to invoke Server Actions
To invoke a Server Action in Next.js, you can use one of the following methods:
- Using the
action
prop.
You can use the action
prop to invoke a Server Action from any HTML element, such as a <button>
, <input type="submit">
, or <form>
.
For example, the following code will invoke the likeThisArticle
Server Action when the user clicks the "Add to Shopping Cart" button:
<button type="button" action={likeThisArticle}>Like this article</button>
- Using the
useFormState
hook.
You can use the useFormState
hook to invoke a Server Action from a <form>
element.
For example, the following code will invoke the addComment
Server Action when the user submits the form:
'use client'
import { useFormState } from 'react';
import { addComment } from '@/actions/add-comment';
export default function ArticleComment({ initialState }) {
const [state, formAction] = useFormState(addComment, initialState)
return (
<form action={formAction}>
Add Comment
</button>
)
}
- Using the
startTransition
hook.
You can use the startTransition
hook to invoke a Server Action from any component in your Next.js application.
For example, the following code will invoke the addComment
Server Action.
'use client'
import { useTransition } from 'react';
import { addComment } from '@/actions/add-comment';
export default function ArticleComment() {
const [isPending, startTransition] = useTransition()
function onAddComment() {
startTransition(() => {
addComment('This article is nothing but great!');
});
}
return (
<button onClick={() => onAddComment()}>
Add Comment
</button>
)
}
The startTransition
hook ensures that the state update batches with other state updates that are happening at the same time. Using startTransition
can improve the performance of your application by reducing the number of re-renders that are required.
Which method should I use?
The best method for invoking a Server Action depends on your specific needs. If you need to invoke a Server Action from a <form>
element, then use the formAction
prop. If you need to invoke a Server Action from a component, then use the startTransition
hook.
Sending Data to an External API Using Server Actions
Now that we have a basic understanding of Server Actions let’s build some real-world examples, like using Server Actions to send data to a third-party API.
To start, you’ll create a Server Action that will trigger from a form submission. As you'll see later, this differs from other functions triggered by JavaScript function calls.
Create a new Server Action, addComment
. This new function will expect one single parameter of the type FormData; here is an example:
// /services/actions/comment
'use server'
export async function addComment(formData) {
const articleId = formData.get('articleId');
const comment = formData.get('comment');
const response = await fetch(`https://api.example.com/articles/${articleId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ comment }),
});
const result = await response.json();
return result;
}
That’s all you need to do to create a Server Action. Let’s focus on invoking this function when the user submits a form.
Let’s create a new component that will render the “Add Comment” form:
import { addComment } from '@/services/actions/comment'
export default async function ArticleComment(props) {
return (
<form action={addComment}>
<input type="text" name="articleId" value={props.articleId} />
<input type="text" name="comment" />
<button type="submit">Add Comment</button>
</form>
)
}
Done! You have a fully functional Server Action triggered from a form submission, but if you tried it out, you’ll notice that it looks unresponsive, meaning that the user is not aware that something is happening as there is no “loading” or “saving” activity shown. Let’s fix that next.
Displaying the loading state
We can use the useFormStatus
hook to display a loading state while a form is being submitted. As a hook, it can only live in a client component and can only be used as a child of a form
element using a Server Action.
Let’s now update our current component, ArticleComment
, and replace the button
with a custom component of our creation called AddCommentButton
.
import { addComment } from '@/services/actions/comment'
import { AddCommentButton } from '@/components/AddCommentButton'
export default async function ArticleComment(props) {
return (
<form action={addComment}>
<input type="text" name="articleId" value={props.articleId} />
<input type="text" name="comment" />
<AddCommentButton />
</form>
)
}
And, of course, you’ll have to create such a component:
'use client'
import { useFormStatus } from 'react-dom'
export function AddCommentButton() {
const { pending } = useFormStatus();
return (
<button type="submit" aria-disabled={pending}>
Add
</button>
)
}
Now, when the form is submitted, the new status will be reflected in the variable pending
and will adjust the button
component accordingly. You can expand this code to provide more visual feedback to the user and create a better user experience.
Submitting Files to Server Actions
If you need to send more than JSON serializable data, like texts and numbers, or more specifically, if you need to submit and handle file uploads, don’t worry. Server Actions have you covered!
You can submit files to Server Actions the same way you have worked with Server Actions so far, by using a form submission with an <input type="file />
or libraries like dropzone
. There’s nothing especially different that needs to happen on the client side for it to work.
On the server side, things could be different depending on your needs. For example, you could save the file, transform the file, etc. The process to retrieve the file would be the same.
Let’s look at a sample Server Action that handles a file upload and transmits the file to an API using a stream:
// /services/actions/upload-file
'use server'
export async function uploadFile(formData) {
const comment = formData.get('file');
const arrayBuffer = await file.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer);
await new Promise((resolve, reject) => {
upload_stream({}, function (error, result) {
if (error) {
reject(error);
return;
}
resolve(result);
})
.end(buffer);
});
}
It is that simple!
You must know that there is a size limitation imposed on Server Actions, which is 1 MB by default, though it can be customized. For more information, check the official Server Action’s size limitation documentation.
Best Practices for Using Server Actions
We have covered so much already about Server Actions, but the topic is so extensive that it won't fit in a single article. At least it wouldn't be practical, but I don't want to leave you without first sharing some of my recommendations and best practices to work with Server Actions and stay further. I also share some tips on accessing protected resources using Auth0 and Next.js Server Actions.
- Decouple of Server Actions. As we mentioned above, it is possible to write Server Actions in a separate file, but in case you don’t expect to re-utilize your function, you could write the same in a Server Component. But, as we learned from experience or the book "Clean Code", separation of concerns is important to keep the code easier to understand, maintain, reuse, and test. So, try to keep a separation between components and actions.
- Don’t disregard the client side. As the line between server and client blurs in the magic of Server Components, it can be easy to forget or disregard the proper handling of the UI. For example, moving from validations to the server could reduce a few lines of code but sacrifice user experience.
- Cache the results of Server Actions. If a Server Action returns data that is not frequently changing, you can cache the results so that they do not have to be fetched from the server every time. Avoiding unnecessary data fetching can improve the performance of your application.
- Use Server Actions to handle errors gracefully. If a Server Action fails, you should handle the error gracefully and return a meaningful error message to the user.
- Protect your Server Actions. When it comes to security, don’t think of your Server Actions any differently than you would if the same code lived in an API endpoint. Server Actions need to be secured and protected from unauthorized access.
Calling a Protected API Endpoint
Protected APIs are not publicly accessible and require some form of authentication or authorization before they can be accessed. This is important for protecting sensitive data and preventing unauthorized access.
There are multiple mechanisms you can implement to protect APIs, for example:
- API Keys: API keys are a simple and effective way to protect APIs. They are secret strings that are given to authorized clients. When a client sends a request to an API, it must include its API key in the request. The API will then check the API key to ensure that it is valid before processing the request.
- Access Tokens: Access tokens are another way to protect APIs. They are short-lived tokens that are generated by an authentication server and are sent to an application in response to a successful user authentication. The application then uses the access token to access the API by sending it in its requests. Access tokens can be implemented in various forms, with a popular option being JSON Web Tokens (JWTs).
API Keys to protect API endpoints
An API key is a secret string of characters that is used to identify and authenticate an application when making requests to an API. API keys are typically used to protect APIs from unauthorized access and to track usage.
While API keys can identify a specific project or application making an API call, they do not identify the individual user making the request. This is a significant security limitation, as it allows anyone with the API key to access the API, regardless of who they are.
To learn more about API keys and how OAuth 2.0 can help mitigate some of its drawbacks, I recommend reading Why You Should Migrate to OAuth 2.0 from API Keys
API keys implementation varies from project to project, so depending on how your target API implements API keys, it may vary the way you use Server Actions to make calls to the API.
Let’s rebuild our addComment
Server Action to pass an API key to the target API.
// /services/actions/comment
'use server'
export async function addComment(formData) {
const articleId = formData.get('articleId');
const comment = formData.get('comment');
const response = await fetch(`https://api.example.com/articles/${articleId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.CMS_API_KEY // 👈 New Code
},
body: JSON.stringify({ comment }),
});
const result = await response.json();
return result;
}
The server function remains the same, with the exception of passing a new parameter on the request headers. In the example above, the header name is x-api-key
, but it may be different depending on the API.
Because Server Actions run in the server, it is safe to read the API keys from environment variables, though you should make sure your environment provider is safe to do so. For example, if you deploy using Vercel, all environmental variable values are automatically encrypted at rest.
API keys are great for multiple scenarios, but sometimes, your API needs to be aware of the user performing the action. For example, our current API is not aware of who is creating the comment, and though it could be added as a payload, it wouldn’t be safe if we couldn’t verify that it was the user who performed the operation.
Access Tokens to protect API endpoints
Access tokens are used in token-based authentication, enabling applications to access APIs. The application receives an access token following a successful user authentication and authorization process. This token is then passed as a credential when making API calls. The presented token informs the API that the bearer has been authorized to access the API and perform specific actions defined by the scope granted during authorization.
So we can break the process in a two-step process:
- Authentication: Identifying a user and retrieving an access token
- Calling an endpoint: Using the received and verifiable access token to call the API endpoint
The process of authenticating, generating, verifying, and securely handling Access Tokens is a daunting and time-consuming task where a mistake can compromise the data and safety of your systems, so it is common for application builders to rely on existing authentication and authorization providers, for example, Auth0.
Authentication is outside the scope of this guide, but if you want to learn more about authentication or you want to know how to implement Auth0 authentication to your Next.js application, please follow the steps on this quick guide or the complete guide on Next.js Authentication. And if you don’t have an Auth0 account yet, you can sign up for a free Auth0 account.
Once your users are authenticated, you can rewrite your Server Action as follows:
// /services/actions/comment
'use server'
import { getAccessToken } from '@auth0/nextjs-auth0' // 👈 New Code
export async function addComment(formData) {
const accessToken = await getAccessToken(); // 👈 New Code
const articleId = formData.get('articleId');
const comment = formData.get('comment');
const response = await fetch(`https://api.example.com/articles/${articleId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken.accessToken}`, // 👈 New Code
},
body: JSON.stringify({ comment }),
});
const result = await response.json();
return result;
}
In this new flow, you’ll need to retrieve the Access Token from your session. The Auth0 SDK makes this easy with the method getAccessToken
. After that, you can simply pass the token to the request using the Authorization
header.
Next, in the third party or external API, you can verify the token to ensure it’s not compromised, and you can decode the token to obtain additional information, such as the user, permissions, and more.
Conclusion
Server Actions are a fantastic new feature in Next.js that allows developers to do more with less code by eliminating much of the boilerplate required to write API codes and their subsequent calling code.
In this guide, we covered the fundamentals of Server Actions to delve into the particularities of calling third-party public and protected API endpoints.
Next.js has many exciting new features, and I'll be exploring more of them in future posts. So, if you would like me to cover any specific Next.js feature, please drop a comment below.
Thanks for reading!