Using Server Actions with Full Type Safety
AI generated
type
TypeScript · React Server Components
Type-Safe Server Actions
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.

10 min read Server Actions React Server Components Forms

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

11. FAQ: Type-Safe Server Actions

1What does 'use server' actually do technically?
The directive marks a function so that build tooling generates its own network endpoint for it. Arguments and return value are serialized between client and server, while the actual execution happens exclusively on the server.
2Why shouldn't I cast FormData directly with as string?
A cast merely shifts the risk into runtime without actually checking whether the field was present and correctly formatted. A validation schema like Zod checks structure and content at the same time and returns structured error messages on failure.
3When should a Server Action throw an error instead of returning one?
For unexpected server errors, such as a database outage, a thrown error makes sense since it flows into the framework's generic error handling. For expected form validation errors, a typed return is the better choice since it allows field level messages.
4How does useActionState relate to a Server Action's signature?
useActionState expects the action's first parameter to be the previous state and the second to be the form data. TypeScript checks this signature against the hook's generic type parameter and reports mismatches as compile errors.
5Why should every Server Action be treated as a public endpoint from a security standpoint?
Every exported action produces its own directly callable HTTP endpoint, regardless of whether a button for it is visible in the UI. Authorization checks must therefore happen inside the action itself.
6What is the difference between revalidatePath and revalidateTag?
revalidatePath invalidates the cache for a specific route based on its path, while revalidateTag invalidates all data tagged with a given tag, regardless of which routes it appears on.
7Does progressive enhancement survive every Server Action?
Only as long as the action actually accepts a FormData instance as its argument and is triggered through the native form action attribute. Signatures with several individual arguments usually need a client side event handler and stop working without JavaScript.
8What is useOptimistic used for?
The hook updates local state immediately while the associated Server Action is still running in the background, automatically falling back to the real server state once the response arrives or the action fails.
9What does a wrapper like next-safe-action offer over handwritten actions?
Such a wrapper unifies validation, authorization and error format through a generic builder, so every action in the project returns consistent, fully typed results instead of repeating that code in each action.
10Can a Server Action accept arbitrary TypeScript objects as arguments?
Only serializable values such as primitives, arrays, plain objects and FormData survive the boundary between client and server unchanged. Functions, class instances with methods, or cyclical structures are unsuitable for this.