iTranslated by AI
Understanding Next.js Server Actions: Mechanisms and Practical Usage
Hello everyone, I'm _mino!
In this article, I will dive deep into the mechanism and practical usage of Next.js Server Actions.
I hope this serves as a helpful resource for those who want to understand Server Actions in depth or use them in their professional projects.
🔥 What are Server Actions?
Server Actions were introduced as an experimental feature in Next.js v13.4 and officially released in v14. By passing them to the action attribute of a <form />, you can define asynchronous functions that execute on the server.
The background of Server Actions is mentioned in the official Vercel blog post, "Server Actions: React’s First Steps into Mutability."
Relationship with Server Functions
Starting with Next.js v15, a new term, "Server Functions," has been added, clearly distinguishing them from the conventional "Server Actions."
The official documentation states:
Before September 2024, we referred to all Server Functions as "Server Actions." While a Server Function passed to an action property or called from an action is a Server Action, not all Server Functions are Server Actions. We have updated the terminology in this documentation to reflect that Server Functions can be used for multiple purposes.
In other words, the current positioning is as follows:
-
Server Functions: A general term for functions defined with
'use server'that execute on the server side. -
Server Actions: Server Functions that are used as actions, such as those passed to
<form action>or called withinstartTransition.
The differences between "Server Functions" and "Server Actions" are neatly summarized in this article for further reference👇
Platform Support Status
Here is the support status of Server Actions across major hosting services. They are basically available on any platform.
| Platform | Support Status | Documentation |
|---|---|---|
| Vercel | ✅ Supported | Next.js Official |
| Netlify | ✅ Supported | Netlify Official |
| AWS Amplify Hosting | ✅ Supported | AWS Official |
| Cloudflare Workers | ✅ Supported | Cloudflare Official |
| Firebase App Hosting | ✅ Supported | Firebase Official |
| Node.js / Docker (EC2, Cloud Run, etc.) | ✅ Supported | Next.js Official |
🏖️ Differences from Route Handlers
What are Route Handlers?
In Next.js, you can build Web API endpoints by creating route.ts (or route.js) files under the app directory. They support HTTP methods like GET, POST, PUT, PATCH, and DELETE.
Comparison of Design Philosophy and Features
Both "Route Handlers" and "Server Actions" are mechanisms for calling server-side code from the client, but they differ in design philosophy and use cases.
Route Handlers follow the traditional REST API approach, directly handling HTTP requests and responses. While they offer high flexibility, you must use fetch() on the caller side and manage types manually.
On the other hand, Server Actions are specialized for data mutations and can be called like regular JavaScript functions. You don't need to write fetch(), and since they are function calls, they have excellent compatibility with TypeScript, allowing for simpler code.
| Item | Route Handlers | Server Actions |
|---|---|---|
| Definition Location |
route.ts file |
'use server' directive |
| HTTP Method | GET, POST, PUT, DELETE, etc. | POST only |
| Calling Method | Via fetch()
|
Direct function call |
| Type Safety | Requires manual typing | TypeScript type inference works well |
| Caching | GET requests can be cached | Not cached |
| External Access | Possible (e.g., Webhooks) | Not possible (Internal to app only) |
| Main Use Case | Data fetching, external integration | Data mutation |
Choosing Between Them for Use Cases
There is no strict rule for choosing between them, but generally, the following split is recommended:
When to use Server Actions
- Submitting contact forms or login forms
- Posting, editing, or deleting articles and comments
- UI operations like "like" buttons or bookmarking
When to use Route Handlers
- Receiving Webhooks from services like Stripe or GitHub
- Providing APIs for external services or third parties
- Access from mobile apps or clients on different domains
This article is a helpful reference👇
📝 Understanding Internal Mechanisms
I will break down the behavior of Server Actions under the hood into three phases:
- Build Time
- Client Execution Time
- Server Execution Time
1. Build Time
Generating Action IDs
During the build, Next.js detects the "use server" directive and generates a unique Action ID for each function.
"use server";
async function actionFun(formData: FormData) {
// ...
}
An Action ID like the following is assigned to this function:
Action ID: "6a88c2d1e4f..."
Generating Client Code
At build time, the function body of the Server Action is excluded from the client bundle. Instead, a "reference code" is generated to send requests to the server using the Action ID.
// Code included in the client bundle
import { createServerReference } from "react-server-dom-webpack/client";
// Not the function body, but a reference to the server
export const actionFun = createServerReference("6a88c2d1e4f...");
createServerReference() receives the Action ID and returns a function that, when called, sends that ID to the server as a POST request.
📎 Source code reference: createServerReference()
This ensures that server-side logic is not exposed to the client, and only the minimum necessary code is delivered.
Generating Manifest Files
Mapping information between Action IDs and actual functions is saved as a manifest file (under .next/server/) at build time.
When the server receives a request, it references this file to identify and execute the function corresponding to the Action ID.
{
"node": {
"6a88c2d1e4f...": {
"workers": {
"app/...": {
"moduleId": "...",
"async": false,
"exportedName": "actionFun",
"filename": "..."
}
},
"layer": {
"app/...": "action-browser"
},
"filename": "...",
"exportedName": "actionFun"
}
},
"edge": {},
"encryptionKey": "..."
}
| Key | Description |
|---|---|
node |
Actions executed in the Node.js runtime |
edge |
Actions executed in the Edge runtime |
workers |
List of pages using this action |
layer |
Execution context for each page |
encryptionKey |
Key used to encrypt closure variables |
2. Client Execution Time
When implementing a form using <form /> and useActionState, <input type="hidden" ... /> elements are automatically generated in the HTML.
These contain information such as the Action ID and bound states.
<form action enctype="multipart/form-data" method="POST">
<input type="hidden" name="$ACTION_REF_1">
<input type="hidden" name="$ACTION_1:0" value="{"id":"600fbcb...","bound":"$@1"}">
<input type="hidden" name="$ACTION_1:1" value="[{"success":false,"error":null,"message":null}]">
<input type="hidden" name="$ACTION_KEY" value="k4e00a4ec...">
...
</form>
When the form is submitted, the following fetch process is executed internally.
async function fetchServerAction(state, nextUrl, { actionId, actionArgs }) {
// Serialize arguments
const body = await encodeReply(actionArgs, { temporaryReferences });
// Build headers
const headers = {
Accept: RSC_CONTENT_TYPE_HEADER, // 'text/x-component'
[ACTION_HEADER]: actionId, // 'Next-Action'
[NEXT_ROUTER_STATE_TREE_HEADER]: prepareFlightRouterStateForRequest(state.tree),
};
// Send POST request to server
const res = await fetch(state.canonicalUrl, { method: 'POST', headers, body });
// Redirect handling, RSC response parsing, error handling...
}
📎 Source code reference: fetchServerAction()
Requests sent to the server include the following information:
| Item | Description |
|---|---|
| Method | Always POST
|
| URL | URL of the current page (e.g., /todos) |
Next-Action Header |
The action ID to execute (e.g., 6a88c2d1e4f...) |
Next-Router-State-Tree Header |
Current router state (for navigation) |
Accept Header |
Specifies RSC format (text/x-component) |
| Body | Serialized arguments (React Flight format) |
3. Server Execution Time
Upon receiving the request from the client, the server identifies and executes the corresponding function based on the Action ID.
async function handleAction(req, serverModuleMap) {
// 1. Get action ID from Next-Action header
const actionId = req.headers.get('Next-Action');
// 2. Get FormData from request body
const formData = await req.formData();
// 3. Identify the function corresponding to the action ID
const action = await decodeAction(formData, serverModuleMap);
if (!action) {
throw new Error(
`Failed to find Server Action "${actionId}". ` +
`This request might be from an older or newer deployment.`
);
}
// 4. Execute function
const result = await action();
// 5. Return result in RSC Payload format
return result;
}
📎 Source code reference: handleAction()
Summary of the processing flow:
- Obtain Action ID from
Next-Actionheader - Search for the function corresponding to the ID in
serverModuleMap(manifest) - Deserialize the arguments and execute the function
- Return the result to the client in RSC Payload format
If revalidatePath or redirect is called within the function, that information is included in the response and handled appropriately on the client side.
This way, developers do not need to write fetch manually, and the above process is performed automatically simply by calling the Server Action as a normal function.
📝 Precautions When Using Server Actions
While Server Actions make implementing server processing easy, there are pitfalls to keep in mind due to their simplicity.
Security Perspective
Server Actions have built-in protection features provided by Next.js.
- Same-Origin Restriction: Compares Origin and Host headers, rejecting requests if they differ. Therefore, Server Actions can only be called from within the application and cannot be accessed from external domains.
- CSRF Protection: Only POST method is permitted. Additionally, browsers default to SameSite cookies, which prevents most CSRF attacks.
- Secure Action IDs: References endpoints with unpredictable encrypted IDs. For security, these IDs are periodically recalculated with every build.
- Dead Code Elimination: Unused Server Actions are removed from the client bundle.
Even with these protections, Server Actions are "public HTTP endpoints". Even if buttons are hidden in the UI, the API itself can be called, and malicious users can send POST requests directly.
The official documentation states:
These IDs are generated at compile time and are cached for up to 14 days. They are regenerated when a new build starts or when the build cache is invalidated. This security enhancement reduces risk when an authentication layer is missing. However, Server Actions should be treated similarly to public HTTP endpoints.
Therefore, the following measures are MUST-HAVE implementations:
1. Requirement for Authentication
For processes requiring login, always verify the authenticated session within the Server Action.
Without this implementation, there is a risk that unauthenticated users could hit the API directly to manipulate data.
async function action(formData: FormData) {
const session = await getSession(); // Using authentication libraries like next-auth
if (!session) {
return { error: "Authentication required", status: 401 };
}
// ...
}
2. Requirement for Validation
Always verify that the input matches the expected type within the Server Action using schema validation libraries like Zod or Valibot.
Without this, there is a risk that unexpected data types or invalid values could be saved to the database, or the application could crash.
async function action(formData: FormData) {
const result = schema.safeParse({
// ...
});
if (!result.success) {
return { error: "Invalid input", status: 400 };
}
// ...
}
3. Error Handling
Log internal errors on the server and return generic messages like "A system error occurred" to the client.
Returning server errors as-is to the client carries the risk of leaking sensitive information such as database connection details or stack traces.
async function action(formData: FormData) {
try {
// ...
} catch (error) {
// ❌ Leaking sensitive info by returning error as-is
return { error: error.message, status: 500 };
// ✅ Log to server and return generic message
console.error(error);
return { error: "A system error occurred", status: 500 };
}
}
4. Controlling Disclosure of Sensitive Info to Client
When handling sensitive data within a Server Action, you can use React's taintUniqueValue or taintObjectReference to prevent accidental transmission to the client. This is mainly to prevent developer mistakes.
While an experimental feature at the time of writing, you can control it as follows:
import { experimental_taintUniqueValue } from "react";
async function action(formData: FormData) {
const [user] = await sql`SELECT * FROM users WHERE id = ${id}`;
experimental_taintUniqueValue(
"Do not pass password_hash to the client",
user,
user.password_hash
);
// ❌ Error if password_hash is included
return { data: user.password_hash, status: 200 };
// ✅ Return only safe fields
return { name: user.name, email: user.email, status: 200 };
}
5. Cache Revalidation
If you have configured caching in Next.js App Router, modifying data via Server Action will not reflect updates unless the cache is revalidated.
To keep data fresh, call updateTag, revalidatePath, or revalidateTag after modifications to update related caches.
async function action(formData: FormData) {
// Invalidate cache for specified tag immediately (Server Actions only)
updateTag("articles")
// Revalidate cache for specified path
revalidatePath("/articles")
// Revalidate cache for specified tag in background
revalidateTag("articles", "max")
}
Others
Beyond the above, you should also consider "Authorization," "Rate Limiting," "Audit Logs," and "Idempotency" depending on your requirements.
📚 Libraries That Work Well with Server Actions
I would like to introduce some libraries I have had a great experience with while using Server Actions.
next-safe-action
A library for handling Next.js Server Actions with type safety.
You can write Server Actions with a feel similar to tRPC.
import { createSafeActionClient } from "next-safe-action"
// Centralize auth checks, error handling, etc.
const actionClient = createSafeActionClient({})
export const action = actionClient
.schema(schema)
.action(async ({ parsedInput }) => {
// DB saving, external API calls, etc.
})
Conform
A type-safe form validation library.
You can manage form state with the useForm() hook and use it comfortably in combination with useActionState.
👀 Conclusion
Thank you for reading until the end! ☺️
I hope this article helps you in your development work!
Discussion