Forms and mutations without a separate API route
Server Actions move mutations directly into server functions that can be called from client components. Without careful typing of inputs, returns and error states, that convenience quickly turns into a source of invisible runtime errors.
Table of Contents
- 1. What a Server Action actually is
- 2. Validating FormData instead of trusting it blindly
- 3. Typed returns instead of thrown errors
- 4. useActionState: form state without a client library
- 5. Controlling revalidation after a mutation, typed
- 6. Security: every Server Action is a public endpoint
- 7. Progressive enhancement without client-side JavaScript
- 8. Coupling optimistic updates with useOptimistic, typed
- 9. Reusable, type-safe action wrappers
- 10. Summary
- 11. FAQ
1. What a Server Action actually is
A Server Action is an asynchronous function marked with the 'use server' directive, guaranteeing it runs exclusively on the server even though it can be called from a client component like a regular function. Under the hood, the framework generates its own network endpoint, serializes arguments and return value, and handles delivery.
For type safety, it matters that this serialization cannot transport every arbitrary TypeScript type. Functions, class instances with methods, or cyclical structures do not survive the boundary between server and client unchanged, which is why signatures should deliberately stick to serializable values such as primitives, arrays, plain objects and FormData.
// app/actions/create-post.ts
'use server';
export async function createPost(formData: FormData): Promise<{ id: string }> {
const title = formData.get('title');
if (typeof title !== 'string' || title.length === 0) {
throw new Error('Title must not be empty');
}
const post = await db.post.create({ data: { title } });
return { id: post.id };
}
2. Validating FormData instead of trusting it blindly
FormData returns FormDataEntryValue | null for every field, never a ready made, specific type. Casting with as string here merely pushes the risk into runtime and loses every guarantee that a field was actually present and correctly formatted.
The robust approach runs raw form data through a schema that converts it into a typed object while bundling every validation rule in one place. With Zod, FormData can be converted into a plain object via Object.fromEntries() and then parsed against a schema that checks both structure and business rules such as minimum length.
'use server';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(3).max(120),
content: z.string().min(1),
});
export async function createPost(formData: FormData) {
const raw = Object.fromEntries(formData);
const result = CreatePostSchema.safeParse(raw);
if (!result.success) {
return { success: false as const, errors: result.error.flatten().fieldErrors };
}
const post = await db.post.create({ data: result.data });
return { success: true as const, id: post.id };
}
3. Typed returns instead of thrown errors
A thrown error inside a Server Action lands in the framework's error path and typically renders a generic error page, which is rarely the desired behavior for form validation errors. A more robust pattern is a discriminated union result that encodes success and failure as regular return values, forcing the calling component to explicitly handle both cases.
This pattern can be formulated as a reusable type shared across multiple actions. The key advantage over a try-catch on the client is that the TypeScript compiler enforces actually checking the error branch before accessing success data.
type ActionResult<T> =
| { success: true; data: T }
| { success: false; errors: Record<string, string[]> };
'use server';
export async function createPost(formData: FormData): Promise<ActionResult<{ id: string }>> {
const result = CreatePostSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return { success: false, errors: result.error.flatten().fieldErrors };
}
const post = await db.post.create({ data: result.data });
return { success: true, data: { id: post.id } };
}
4. useActionState: form state without a client library
The useActionState hook wires a Server Action directly into the local render state of a client component. It accepts the action plus an initial state and returns the current state, a function that can be passed to form action, and a pending flag, all without an additional state management library.
For this mechanism to stay type safe, the action's signature has to match the expected state type: the first parameter is always the previous state, the second the form data. TypeScript checks this signature against the hook's generic type parameter and reports any mismatch immediately as a compile error.
'use client';
import { useActionState } from 'react';
import { createPost, type ActionResult } from '../actions/create-post';
const initialState: ActionResult<{ id: string }> | null = null;
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<input name="title" required />
{state && !state.success && (
<p>{state.errors.title?.[0]}</p>
)}
<button disabled={isPending}>Save</button>
</form>
);
}
5. Controlling revalidation after a mutation, typed
After a successful Server Action, cached page content has to be refreshed so users actually see the change. Functions like revalidatePath and revalidateTag accept a string path or a tag name for this, which looks untyped at first glance but is deliberately designed that way, since paths and tags are often assembled dynamically at runtime from route data.
To avoid typos in frequently used tags, a central module with named constants is worth the investment instead of scattered string literals. That keeps revalidation maintainable in one place, even though the underlying call itself offers no strict type safety for the path.
// app/cache-tags.ts
export const CacheTags = {
posts: 'posts',
post: (id: string) => `post:${id}` as const,
} as const;
'use server';
import { revalidateTag } from 'next/cache';
import { CacheTags } from '../cache-tags';
export async function updatePost(id: string, formData: FormData) {
await db.post.update({ where: { id }, data: { /* ... */ } });
revalidateTag(CacheTags.post(id));
revalidateTag(CacheTags.posts);
}
6. Security: every Server Action is a public endpoint
A frequently underestimated property of Server Actions is that every exported action produces its own directly callable HTTP endpoint, regardless of whether it is actually linked anywhere in the UI. Authorization checks therefore belong inside the action itself and must never rely on a button being hidden in the interface.
Type safety helps here indirectly: a central wrapper that loads a session, checks its type, and only then runs the actual action logic, enforces through its type signature that every protected action truly operates on a validated session variable instead of accidentally letting an unchecked request through.
async function withAuth<T>(
action: (session: Session, formData: FormData) => Promise<T>,
) {
return async (formData: FormData) => {
const session = await getSession();
if (!session) {
throw new Error('Not authenticated');
}
return action(session, formData);
};
}
export const deletePost = withAuth(async (session, formData) => {
const id = String(formData.get('id'));
await db.post.delete({ where: { id, authorId: session.userId } });
});
7. Progressive enhancement without client-side JavaScript
Because Server Actions can be triggered through the native form action attribute, the form generally still works before JavaScript has loaded in the browser, as long as the action actually accepts a FormData instance rather than arbitrary arguments. That property is lost the moment an action expects several individual, non serializable arguments that can only be assembled through a client side event handler.
For forms where progressive enhancement matters, the signature should deliberately stay at (formData: FormData) => Promise, while more complex, purely client driven interactions such as drag and drop ordering are better solved with pre bound arguments via bind() or dedicated client calls.
8. Coupling optimistic updates with useOptimistic, typed
The useOptimistic hook lets local state update immediately while the associated Server Action is still running in the background, automatically falling back to the real state once the server response arrives or fails. The hook's generic type parameter describes both the base state and the reducer function that computes an optimistic update.
For this pattern to remain type safe, the shape of the optimistic update has to match the type of the actual server state exactly. TypeScript checks that the reducer, given current state and an action, produces the same state type again, which surfaces inconsistent intermediate states early.
'use client';
import { useOptimistic } from 'react';
type Post = { id: string; title: string; pending?: boolean };
export function PostList({ posts }: { posts: Post[] }) {
const [optimisticPosts, addOptimisticPost] = useOptimistic(
posts,
(state: Post[], newTitle: string) => [
...state,
{ id: 'temp', title: newTitle, pending: true },
],
);
return (
<ul>
{optimisticPosts.map((p) => (
<li key={p.id}>{p.title}{p.pending ? ' (saving...)' : ''}</li>
))}
</ul>
);
}
9. Reusable, type-safe action wrappers
In larger projects, a central wrapper that unifies validation, authorization and error format across all Server Actions pays off, instead of repeating that code in every single action. Libraries such as next-safe-action offer generic builders for this, combining a Zod schema, an optional authorization check and the actual logic into one fully typed action.
The effect of such a wrapper is that validation errors, server errors and success cases are returned to the client consistently through the same discriminated union structure, regardless of which developer wrote the concrete action. That reduces boilerplate and prevents individual actions from accidentally leaking raw, unchecked errors to the outside.
| Aspect | Thrown error | Typed return (ActionResult) | Recommendation |
|---|---|---|---|
| Client handling | Ends up in an error boundary | Must be checked explicitly | Typed return for form errors |
| Type safety | Error type usually unknown | Discriminated union with fields | Return type forces the compiler check |
| UX on validation | Generic error page | Field level error messages possible | Return type for forms |
| Best suited for | Unexpected server errors | Expected validation errors | Combine both mechanisms |
Mironsoft
TypeScript migration, type safety, and team onboarding
A JavaScript codebase without type safety, but no time for a full migration?
We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.
Migration Roadmap
Plan and execute a gradual JS-to-TS migration without big-bang risk.
Strict Mode Rollout
Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.
Team Onboarding
Bring developers up to speed on TypeScript best practices with workshops and reviews.
10. Summary
Type-Safe Server Actions
Directive
'use server' marks a function as a server executed endpoint
Validation
Turn FormData into a typed object via a Zod schema
Error pattern
Discriminated ActionResult union instead of thrown errors
Security
Authorization inside the action itself, not just hidden in the UI