iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🎉

Understanding Next.js Server Actions: Mechanisms and Practical Usage

に公開
2026/02/24

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.
https://nextjs.org/docs/app/getting-started/updating-data

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.

https://react.dev/reference/rsc/server-functions

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 within startTransition.

The differences between "Server Functions" and "Server Actions" are neatly summarized in this article for further reference👇

https://zenn.dev/b13o/articles/actions-vs-serverfunctions

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.

https://nextjs.org/docs/app/getting-started/route-handlers

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👇
https://zenn.dev/coconala/articles/82a805fa39e76b

📝 Understanding Internal Mechanisms

I will break down the behavior of Server Actions under the hood into three phases:

  1. Build Time
  2. Client Execution Time
  3. 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:

  1. Obtain Action ID from Next-Action header
  2. Search for the function corresponding to the ID in serverModuleMap (manifest)
  3. Deserialize the arguments and execute the function
  4. 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.

https://nextjs.org/docs/app/guides/data-security#mutating-data

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.

https://nextjs.org/docs/app/api-reference/config/next-config-js/taint

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.
https://next-safe-action.dev/

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.
https://conform.guide/

👀 Conclusion

Thank you for reading until the end! ☺️
I hope this article helps you in your development work!

Discussion