Server Actions (Next.js, Remix, TanStack Start) feel magical: call a function from the client; it runs on the server. By 2026 the patterns are clear: where they shine, where they fall short. This post is the working set.

What Server Actions are

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

export async function createPost(formData: FormData) {
    const title = formData.get("title") as string;
    const post = await db.post.create({ data: { title } });
    return post;
}

// In any component
<form action={createPost}>
    <input name="title" />
    <button>Post</button>
</form>

The framework wires up an HTTP endpoint behind the scenes. Client calls; server runs.

Where they shine

  • First-party UI mutations: signup, settings update, delete, etc.
  • Tight coupling: action and form built together.
  • Auto-revalidation: mutate → invalidate cache → UI updates.
  • Type safety: function signatures shared client/server.
  • Less boilerplate: no /api/posts route to maintain.

Where they fall short

  • Mobile clients: can’t call Server Actions from React Native (well).
  • Third-party integrations: no public contract.
  • Multi-tenant SDKs: no clean way to expose.
  • Versioning: no built-in API versioning.
  • Caching at edge: harder than REST GETs.

When Server Actions

Use Server Actions when:

  • Your client is the same framework’s UI.
  • The mutation is form-shaped.
  • Audience is internal (your UI users).
// Settings update
async function updateSettings(prevState, formData) {
    "use server";
    const userId = (await auth()).id;
    await db.settings.update({
        where: { userId },
        data: { theme: formData.get("theme") },
    });
    revalidatePath("/settings");
}

Clean. No API route. Works with React Hook Form etc.

When traditional APIs

Use REST / GraphQL / tRPC when:

  • Multiple clients: web + mobile + integrations.
  • Public API: third parties build on you.
  • Long-lived contracts: versioning, deprecation.
  • Read-heavy paths benefiting from CDN cache.
  • Streaming / long polling.

Server Actions can do reads but it’s awkward; designed for mutations.

Hybrid pattern

[Web UI (Next.js)] ──Server Actions──▶ [Server logic]
                                       [Service layer]
[Mobile / Third party] ──REST───────────[REST endpoints]

Both UIs share the same service layer. Server Actions and REST are thin shells.

Server Action security

"use server";

export async function deletePost(postId: string) {
    const session = await auth();
    if (!session) throw new Error("unauthorized");
    
    const post = await db.post.findUnique({ where: { id: postId } });
    if (post.authorId !== session.userId) throw new Error("forbidden");
    
    await db.post.delete({ where: { id: postId } });
    revalidatePath("/posts");
}

Auth + authz on every action. The “function call” feel doesn’t exempt you. Treat each Server Action as an authenticated endpoint.

Validate inputs:

import { z } from "zod";

const Schema = z.object({ title: z.string().min(1).max(200) });

export async function createPost(formData: FormData) {
    "use server";
    const data = Schema.parse({ title: formData.get("title") });
    // ...
}

See Zod 2026 .

Errors and feedback

"use server";
export async function createPost(prevState, formData) {
    try {
        const post = await db.post.create({ data: parseInput(formData) });
        return { success: true, post };
    } catch (e) {
        return { success: false, error: e.message };
    }
}

// Client
const [state, action, pending] = useActionState(createPost, null);
return (
    <form action={action}>
        <input name="title" />
        <button disabled={pending}>{pending ? "Saving..." : "Save"}</button>
        {state?.error && <p>{state.error}</p>}
    </form>
);

useActionState for error/loading state. Cleaner than handling in React state manually.

Optimistic updates

const [optimistic, addOptimistic] = useOptimistic(posts);

async function action(formData) {
    addOptimistic({ id: "temp", title: formData.get("title") });
    await createPost(formData);
}

UI updates instantly; reconciles when action resolves. See TanStack Query 2026 for similar patterns.

Server Actions and tRPC

Both can coexist:

  • tRPC: queries (reads) and complex mutations.
  • Server Actions: form mutations.

Some teams use Server Actions exclusively; others prefer tRPC for typed mutations across forms and code paths. Matter of taste.

Gotchas

  • Form-only: you can call from non-form, but the standard ergonomics assume forms.
  • Cache implications: forget revalidate → UI shows stale.
  • Big payloads: actions accept FormData; large multipart works but not always pretty.
  • Streaming responses from actions: limited.

Common mistakes

1. Auth in middleware only

Forgetting per-action checks; trust middleware. Belt + suspenders: auth in middleware AND in the action.

2. No input validation

formData.get("price") as int trusted. Zod-validate everything.

3. No error handling

Action throws; UI shows blank. Catch and surface.

4. Forgetting revalidation

Mutation succeeds; UI shows old. revalidatePath or revalidateTag.

5. Server Actions for things that should be REST

You exposed deleteUser as Server Action; now mobile team needs it; can’t reach. Plan for client diversity.

What I’d ship today

For new full-stack TS apps:

  • Server Actions for form mutations in first-party UI.
  • REST or tRPC for everything else.
  • Service layer shared by both.
  • Zod validation at every boundary.
  • Auth + authz in every action.
  • Tests that hit actions like APIs.

Read this next

If you want my Next.js Server Actions + service layer pattern, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .