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/postsroute 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 .