Guides

Server Actions

Server actions are server-side mutation functions that Mreact can bind to rendered forms. The browser receives an action reference, while the action implementation stays in the server graph and is stripped from production client bundles.

What server actions are for

Use server actions for form-first mutations inside your Mreact UI: create a record, update settings, save a draft, delete an item, or submit a workflow step. Use a page loader when the work is only data for rendering a page, and use HTTP APIs when external clients, webhooks, or custom fetch callers need a stable endpoint.

Server actions pair well with route cache invalidation because the mutation and the route that should be refreshed usually live in the same app surface.

Create a form action

Put the action in a server-side module and import it into the route that renders the form. The action receives the submitted FormData, and imported functions passed to <form action={...}> are inferred as server actions. A top-level "use server" directive is still supported and is a clear marker that exported functions in that module are server actions.

Refresh a list after creating a record

A common pattern is to render the list with a page loader, submit a small form, invalidate the list route, and let the current route refresh. This keeps the page useful before and after the mutation instead of showing a form-only example.

// src/app/notes/actions.ts
"use server";

import { revalidatePath } from "@reckona/mreact-router";
import { createNote } from "../../lib/notes.js";

export async function saveNote(formData: FormData): Promise<void> {
  const text = String(formData.get("text") ?? "").trim();

  if (text.length === 0) {
    return;
  }

  await createNote({ text });
  revalidatePath("/notes");
}
// src/app/notes/page.tsx
import { definePage } from "@reckona/mreact-router";
import { Link } from "@reckona/mreact-router/link";
import { listNotes } from "../../lib/notes.js";
import { saveNote } from "./actions.js";

export async function loader() {
  return {
    notes: await listNotes(),
  };
}

export default definePage<typeof loader>(function Page(props) {
  const notes = props.data.notes;

  return (
    <main>
      <h1>Notes</h1>

      <ul>
        {notes.map((note) => (
          <li key={note.id}>
            <Link href={`/notes/${note.id}`}>{note.title}</Link>
          </li>
        ))}
      </ul>

      <form method="post" action={saveNote}>
        <label>
          Note
          <input name="text" required />
        </label>
        <button type="submit">Save</button>
      </form>
    </main>
  );
});

When the enhanced browser runtime handles this form, revalidatePath("/notes") can refresh the visible list with a single-flight mutation response. Without the enhanced runtime, the same action still invalidates the route cache and falls back to normal navigation behavior.

Redirect to the created record

If the next useful screen is the new note detail page, create the record and redirect to it from the action. Revalidate both the list route and the detail route when both pages can be cached.

// src/app/notes/actions.ts
"use server";

import { redirect, revalidatePath } from "@reckona/mreact-router";
import { createNote } from "../../lib/notes.js";

export async function saveNote(formData: FormData): Promise<void> {
  const text = String(formData.get("text") ?? "").trim();

  if (text.length === 0) {
    return;
  }

  const note = await createNote({ text });

  revalidatePath("/notes");
  revalidatePath(`/notes/${note.id}`);
  redirect(`/notes/${note.id}`);
}

The browser follows the redirect to the detail route. That is different from the current-route single-flight refresh: use single-flight when the user should stay on the same route with fresh HTML, and use redirect() when the mutation should move the user to another route.

Use request context

An inferred form action can accept ServerActionContext as its second argument. Use it when the mutation needs request metadata such as cookies, headers, the original Request, or the best available client IP.

"use server";

import type { ServerActionContext } from "@reckona/mreact-router";

export async function saveNote(
  formData: FormData,
  context: ServerActionContext,
) {
  const session = context.cookies.get("session");
  const requestId = context.headers.get("x-request-id");
  const text = String(formData.get("text") ?? "");

  await saveAuditLog({
    requestId,
    session,
    text,
    url: context.request.url,
  });
}

Keep authorization and validation in the action or in helpers called by the action. The form reference proves that the user submitted a rendered form, but it does not replace application-level permission checks.

Revalidate cached routes

Call revalidatePath() after a mutation changes data used by cached route HTML. Without explicit invalidation, a route with revalidate can keep serving the old snapshot until its timer expires.

// src/app/notes/page.tsx
export const revalidate = 60;
// src/app/notes/actions.ts
"use server";

import { revalidatePath } from "@reckona/mreact-router";

export async function saveNote(formData: FormData) {
  await createNote({
    text: String(formData.get("text") ?? ""),
  });

  revalidatePath("/notes");
}

Use the same path users navigate to. If the mutation affects multiple cached pages, revalidate each route that depends on the changed data.

Single-flight mutation responses

Browser-enhanced server action forms can update the visible route with a single-flight mutation response. When an action mutates data, calls revalidatePath() for the current route, and returns normally, Mreact can render fresh navigation HTML into the action POST response and mark it with x-mreact-action-single-flight.

The browser applies that HTML to the current route without a second GET. Mreact still sends x-mreact-revalidate so unsupported flows can fall back to client navigation cache invalidation, a redirect, or a follow-up navigation.

This is a transport optimization, not an application idempotency system. It updates the route after a successful mutation; it does not decide whether two submitted business operations are duplicates.

Application idempotency keys

Server action nonce replay protection prevents the same rendered action nonce from being accepted twice. That protects the action transport from replay, but it is not a full business-level idempotency system. If two different rendered forms can submit the same logical mutation, or if a user can retry from another tab, add an application-level mutation key.

For a single Node process, a small in-flight map is enough to demonstrate the pattern. Use a durable store, queue, database unique constraint, Redis lock, Cloudflare Durable Object, or another shared primitive when the app runs on multiple instances.

// src/app/notes/page.tsx
import { saveNote } from "./actions.js";

export default function Page() {
  const mutationKey = crypto.randomUUID();

  return (
    <form method="post" action={saveNote}>
      <input type="hidden" name="mutationKey" value={mutationKey} />
      <input name="text" required />
      <button type="submit">Save</button>
    </form>
  );
}
// src/app/notes/actions.ts
"use server";

import { revalidatePath, type ServerActionContext } from "@reckona/mreact-router";

const inFlight = new Map<string, Promise<void>>();

export async function saveNote(
  formData: FormData,
  context: ServerActionContext,
): Promise<void> {
  const session = context.cookies.get("session") ?? "anonymous";
  const mutationKey = String(formData.get("mutationKey") ?? "");
  const text = String(formData.get("text") ?? "").trim();
  const key = `${session}:${mutationKey}`;

  if (mutationKey === "" || text === "") {
    return;
  }

  const existing = inFlight.get(key);
  if (existing !== undefined) {
    return existing;
  }

  const operation = (async () => {
    await createNoteOnce({ mutationKey, session, text });
    revalidatePath("/notes");
  })();

  inFlight.set(key, operation);

  try {
    await operation;
  } finally {
    inFlight.delete(key);
  }
}

The example coalesces concurrent submissions in one process. It does not stop a duplicate after the first operation has completed. For durable idempotency, store mutationKey with the created record and enforce a database unique constraint, or use a shared idempotency table before doing side effects.

How actions are inferred

Mreact follows supported static form action references, including direct imported functions and registry-style member expressions such as actions.save.

import * as actions from "./actions.js";

export default function Page() {
  return (
    <form method="post" action={actions.save}>
      <input name="title" required />
      <button type="submit">Save</button>
    </form>
  );
}

The router lowers the form to a server action reference and registers only the referenced export in generated production manifests. Dynamic action selection is intentionally limited; pass a concrete action function or supported member reference so the compiler can tell which action is exposed.

Production dispatch and manifests

Production server action dispatch is fail-closed when no generated or explicit action manifest is present. Built app-router deployments pass the generated manifest automatically, so normal mreact-router build outputs do not need manual allowlists.

Direct production integrations that intentionally expose every registered action must opt in with serverActions: { allowedActions: "any" }. Otherwise pass the generated allowedActions array so the dispatcher only accepts known actions.

startServer({
  outDir: ".mreact",
  serverActions: {
    allowedActions: "any",
  },
});

Use "any" only when the integration is deliberately exposing all registered actions. For most production paths, prefer the generated manifest.

Limits and security

Server action requests reject Content-Length values over 10 MiB by default before parsing FormData or JSON. Configure the limit when your app has a real need for larger submissions.

JSON action calls also use default structural limits before action validation, authorization, or invocation. Mreact rejects excessively deep values, very large arrays, very large plain objects, and prototype-shaped keys such as __proto__, prototype, and constructor; keep action-specific validation in your own action for business rules and use HTTP APIs when clients need a general JSON endpoint.

startServer({
  outDir: ".mreact",
  serverActions: {
    maxBodyBytes: 2 * 1024 * 1024,
    maxFormFields: 100,
  },
});

Rendered form actions include a CSRF-bound action reference token and nonce. Single-process deployments can use the automatically generated process secret, but multi-instance deployments should set the same MREACT_SERVER_ACTION_SECRET value on every instance so a form rendered by one instance can submit to another.

Use server actions for same-site rendered forms. Use HTTP APIs plus explicit authentication, CSRF, and request validation when another client needs to call the mutation directly.

When not to use server actions

Do not use server actions for public webhooks, mobile API endpoints, third-party integrations, or arbitrary JSON APIs. Use HTTP APIs for those cases. Do not use them for read-only page data either; use Data Loading so status codes, metadata, and route caching stay tied to the page render.