Back to Blog

Next.js Server Actions Advanced Patterns: Progressive Enhancement, Optimistic Updates, and Error Handling

Advanced Next.js Server Actions patterns for production. Covers progressive enhancement with useFormState, optimistic updates with useOptimistic, structured error returns, revalidation after mutations, file uploads in Server Actions, and rate limiting.

Viprasol Tech Team
June 11, 2027
13 min read

Server Actions became stable in Next.js 14, but most teams use them only for the simplest cases: a form that calls a server function. The production patterns β€” progressive enhancement without JavaScript, optimistic updates that feel instant, structured error returns with field-level validation, and rate limiting β€” require a more deliberate approach.

Typed Action Result Pattern

// lib/actions/types.ts
// Every Server Action returns this union type β€” never throws to the client

export type ActionResult<T = void> =
  | { success: true;  data: T }
  | { success: false; error: string; fieldErrors?: Record<string, string[]> };

// Helper constructors
export const ok  = <T>(data: T): ActionResult<T>  => ({ success: true, data });
export const err = (
  error:       string,
  fieldErrors?: Record<string, string[]>
): ActionResult<never> => ({ success: false, error, fieldErrors });

Server Action with Zod Validation

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

import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth/server";
import { revalidatePath } from "next/cache";
import { ok, err, type ActionResult } from "@/lib/actions/types";

const CreateProjectSchema = z.object({
  name:        z.string().min(1, "Name is required").max(100),
  description: z.string().max(500).optional(),
  color:       z.enum(["blue", "green", "red", "yellow", "purple"]),
});

type CreateProjectInput = z.infer<typeof CreateProjectSchema>;

export async function createProject(
  input: CreateProjectInput
): Promise<ActionResult<{ id: string; name: string }>> {
  // 1. Auth
  const session = await requireAuth();

  // 2. Validate
  const parsed = CreateProjectSchema.safeParse(input);
  if (!parsed.success) {
    return err("Validation failed", parsed.error.flatten().fieldErrors);
  }

  // 3. Rate limit check (Redis-based)
  const canProceed = await checkRateLimit(`create-project:${session.workspaceId}`, 10, 3600);
  if (!canProceed) {
    return err("Rate limit exceeded. You can create up to 10 projects per hour.");
  }

  // 4. Business logic
  try {
    const project = await prisma.project.create({
      data: {
        name:        parsed.data.name,
        description: parsed.data.description,
        color:       parsed.data.color,
        workspaceId: session.workspaceId,
        createdById: session.userId,
      },
      select: { id: true, name: true },
    });

    // 5. Invalidate affected pages
    revalidatePath("/dashboard/projects");
    revalidatePath(`/dashboard`);

    return ok(project);
  } catch (e) {
    console.error("[createProject] DB error:", e);
    return err("Failed to create project. Please try again.");
  }
}

🌐 Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different β€” senior engineers only, direct Slack access, and a 5.0β˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript β€” production-grade stack
  • Fixed-price contracts β€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

useFormState: Progressive Enhancement

// Works with and without JavaScript β€” critical for forms on slow connections
// components/create-project-form.tsx
"use client";

import { useFormState, useFormStatus } from "react-dom";
import { createProjectFromFormData } from "@/app/actions/projects-form";

// Separate Server Action for FormData (for progressive enhancement)
// app/actions/projects-form.ts
// "use server";
// export async function createProjectFromFormData(
//   prevState: ActionResult | null,
//   formData: FormData
// ): Promise<ActionResult> {
//   const input = {
//     name:  formData.get("name") as string,
//     color: formData.get("color") as string,
//   };
//   return createProject(input);
// }

const initialState = null;

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-60"
    >
      {pending ? "Creating…" : "Create Project"}
    </button>
  );
}

export function CreateProjectForm() {
  const [state, formAction] = useFormState(createProjectFromFormData, initialState);

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name" className="block text-sm font-medium">Project name</label>
        <input
          id="name"
          name="name"
          type="text"
          required
          className="mt-1 w-full border rounded-lg px-3 py-2"
          aria-describedby={state?.success === false ? "name-error" : undefined}
        />
        {state?.success === false && state.fieldErrors?.name && (
          <p id="name-error" className="text-sm text-red-600 mt-1">
            {state.fieldErrors.name[0]}
          </p>
        )}
      </div>

      <div>
        <label htmlFor="color" className="block text-sm font-medium">Color</label>
        <select id="color" name="color" className="mt-1 w-full border rounded-lg px-3 py-2">
          <option value="blue">Blue</option>
          <option value="green">Green</option>
          <option value="red">Red</option>
        </select>
      </div>

      {/* Global error (not field-specific) */}
      {state?.success === false && !state.fieldErrors && (
        <p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">
          {state.error}
        </p>
      )}

      {/* Success message */}
      {state?.success === true && (
        <p className="text-sm text-green-700 bg-green-50 px-3 py-2 rounded-lg">
          Project created successfully!
        </p>
      )}

      <SubmitButton />
    </form>
  );
}

useOptimistic: Instant Feedback

// components/task-list.tsx β€” tasks appear instantly before server confirms
"use client";

import { useOptimistic, useTransition } from "react";
import { toggleTaskComplete } from "@/app/actions/tasks";

interface Task {
  id:        string;
  title:     string;
  completed: boolean;
}

interface TaskListProps {
  tasks: Task[];
}

export function TaskList({ tasks }: TaskListProps) {
  const [optimisticTasks, addOptimistic] = useOptimistic(
    tasks,
    // Reducer: apply optimistic state
    (state: Task[], { id, completed }: { id: string; completed: boolean }) =>
      state.map((t) => (t.id === id ? { ...t, completed } : t))
  );

  const [, startTransition] = useTransition();

  async function handleToggle(task: Task) {
    const newCompleted = !task.completed;

    startTransition(async () => {
      // 1. Optimistic update (instant)
      addOptimistic({ id: task.id, completed: newCompleted });

      // 2. Actual server call (may take 200–500ms)
      const result = await toggleTaskComplete(task.id, newCompleted);

      if (!result.success) {
        // React will revert the optimistic state automatically
        // when the transition settles with the real server state
        console.error("[toggleTask] Failed:", result.error);
      }
    });
  }

  return (
    <ul className="space-y-2">
      {optimisticTasks.map((task) => (
        <li key={task.id} className="flex items-center gap-3">
          <button
            onClick={() => handleToggle(task)}
            className={`w-5 h-5 rounded border-2 flex items-center justify-center
              ${task.completed ? "bg-blue-600 border-blue-600" : "border-gray-300"}`}
            aria-label={task.completed ? "Mark incomplete" : "Mark complete"}
          >
            {task.completed && (
              <svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 12 12">
                <path d="M10 3L5 8.5 2 5.5" stroke="currentColor" strokeWidth="2" fill="none" />
              </svg>
            )}
          </button>
          <span className={task.completed ? "line-through text-gray-400" : ""}>
            {task.title}
          </span>
        </li>
      ))}
    </ul>
  );
}

πŸš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4–8 weeks, full platforms in 3–5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

File Uploads in Server Actions

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

import { writeFile } from "fs/promises";
import { join } from "path";
import { requireAuth } from "@/lib/auth/server";
import { ok, err, type ActionResult } from "@/lib/actions/types";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ region: process.env.AWS_REGION! });

export async function uploadAvatar(
  formData: FormData
): Promise<ActionResult<{ url: string }>> {
  const session = await requireAuth();

  const file = formData.get("avatar") as File | null;
  if (!file) return err("No file provided");

  // Validate
  const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
  const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB

  if (!ALLOWED_TYPES.includes(file.type)) {
    return err("Only JPEG, PNG, and WebP images are allowed");
  }
  if (file.size > MAX_SIZE_BYTES) {
    return err("File size must be under 5 MB");
  }

  const buffer = Buffer.from(await file.arrayBuffer());
  const ext    = file.type.split("/")[1];
  const key    = `avatars/${session.userId}-${Date.now()}.${ext}`;

  try {
    await s3.send(new PutObjectCommand({
      Bucket:      process.env.S3_BUCKET!,
      Key:         key,
      Body:        buffer,
      ContentType: file.type,
      CacheControl: "max-age=31536000",
    }));

    const url = `https://${process.env.CDN_DOMAIN}/${key}`;
    return ok({ url });
  } catch (e) {
    console.error("[uploadAvatar] S3 error:", e);
    return err("Upload failed. Please try again.");
  }
}

Revalidation Patterns

// app/actions/cache-patterns.ts
"use server";

import { revalidatePath, revalidateTag } from "next/cache";

// Pattern 1: Revalidate specific path after mutation
export async function updateProjectName(id: string, name: string) {
  await prisma.project.update({ where: { id }, data: { name } });

  revalidatePath(`/dashboard/projects/${id}`);  // Specific project page
  revalidatePath("/dashboard/projects");          // Project list
  revalidatePath("/dashboard", "layout");         // Layout (if project name shown in nav)
}

// Pattern 2: Tag-based revalidation (more targeted)
// In your data-fetching function, tag the cache:
// fetch('/api/projects', { next: { tags: ['projects', `project-${id}`] } })

export async function deleteProject(id: string) {
  await prisma.project.delete({ where: { id } });

  revalidateTag(`project-${id}`);  // Any fetch tagged with this
  revalidateTag("projects");        // The project list cache
}

// Pattern 3: redirect after form submit (replaces revalidatePath)
import { redirect } from "next/navigation";

export async function createAndRedirect(formData: FormData) {
  const project = await prisma.project.create({
    data: { name: formData.get("name") as string },
    select: { id: true },
  });

  redirect(`/dashboard/projects/${project.id}`);  // Navigates after creation
}

Cost and Timeline Estimates

ScopeTeamTimelineCost Range
Server Actions + Zod validation + typed results1 dev1–2 days$400–800
useFormState progressive enhancement forms1 dev1 day$300–600
useOptimistic for list mutations1 dev1 day$300–600
File upload Server Action (S3 or local)1 dev1 day$300–600

See Also


Working With Viprasol

Server Actions production patterns require discipline: always return a typed result union (never throw), always validate with Zod before touching the database, always use useFormStatus for pending state (not local state), and always fire revalidatePath for the affected pages. The useOptimistic pattern makes list mutations feel instant while the server catches up β€” React reverts automatically if the server call fails.

What we deliver:

  • ActionResult<T> union type: { success: true; data: T } | { success: false; error; fieldErrors }
  • ok() / err() constructors for clean action returns
  • createProject: requireAuth β†’ safeParse β†’ rate limit β†’ Prisma create β†’ revalidatePath β†’ ok()
  • useFormState with createProjectFromFormData(prevState, formData) signature for progressive enhancement
  • useFormStatus pending state in SubmitButton sub-component (can't use in same form component)
  • useOptimistic reducer pattern: addOptimistic() inside startTransition β†’ auto-revert on failure
  • uploadAvatar: FormData file.arrayBuffer() β†’ S3 PutObjectCommand β†’ CDN URL
  • Revalidation: revalidatePath(path) for path-based, revalidateTag(tag) for tag-based, redirect() for post-submit navigation

Talk to our team about your Next.js forms and mutations β†’

Or explore our web development services.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms β€” we build it all with Next.js and React.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse β‰₯90 scores, mobile-first design, and full source code ownership. Senior engineers only β€” from architecture through deployment.