Back to Blog

Next.js Server Actions and Forms: useActionState, Optimistic UI, and File Uploads

Complete guide to Next.js Server Actions for form handling. Covers useActionState, progressive enhancement, optimistic updates with useOptimistic, file uploads to S3, and validation with Zod.

Viprasol Tech Team
March 7, 2027
13 min read

Server Actions changed how forms work in Next.js. Instead of building an API route, wiring up a fetch call, handling loading state manually, and hoping the form degrades gracefully without JavaScript โ€” you define an async function on the server, pass it to a form's action prop, and Next.js handles the rest. The form works with JavaScript disabled. Validation runs server-side. No API endpoint to secure separately.

This guide covers the full Server Actions form stack for 2027 Next.js applications: basic actions, useActionState for feedback, useOptimistic for instant UI, file uploads to S3, and patterns for complex forms.

Basic Server Action

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

import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";

const ContactSchema = z.object({
  firstName: z.string().min(1, "First name is required").max(100),
  lastName: z.string().min(1, "Last name is required").max(100),
  email: z.string().email("Invalid email address"),
  phone: z.string().optional(),
  company: z.string().optional(),
});

export type ContactFormState = {
  success: boolean;
  error?: string;
  fieldErrors?: Partial<Record<keyof z.infer<typeof ContactSchema>, string[]>>;
  data?: { id: string };
};

export async function createContact(
  _prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const session = await auth();
  if (!session?.user) {
    return { success: false, error: "Unauthorized" };
  }

  const raw = {
    firstName: formData.get("firstName"),
    lastName: formData.get("lastName"),
    email: formData.get("email"),
    phone: formData.get("phone") || undefined,
    company: formData.get("company") || undefined,
  };

  const parsed = ContactSchema.safeParse(raw);
  if (!parsed.success) {
    return {
      success: false,
      fieldErrors: parsed.error.flatten().fieldErrors as ContactFormState["fieldErrors"],
    };
  }

  // Check for duplicate email
  const existing = await prisma.contact.findFirst({
    where: {
      email: parsed.data.email,
      workspaceId: session.user.organizationId,
    },
  });

  if (existing) {
    return {
      success: false,
      fieldErrors: { email: ["A contact with this email already exists"] },
    };
  }

  const contact = await prisma.contact.create({
    data: {
      ...parsed.data,
      workspaceId: session.user.organizationId,
    },
    select: { id: true },
  });

  revalidatePath("/contacts");

  return { success: true, data: { id: contact.id } };
}

useActionState: Form with Validation Feedback

// app/contacts/new/page.tsx
"use client";

import { useActionState } from "react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { createContact, type ContactFormState } from "../actions";
import { Loader2 } from "lucide-react";

const initialState: ContactFormState = { success: false };

export default function NewContactPage() {
  const router = useRouter();
  const [state, action, isPending] = useActionState(createContact, initialState);

  // Redirect on success
  useEffect(() => {
    if (state.success && state.data?.id) {
      router.push(`/contacts/${state.data.id}`);
    }
  }, [state.success, state.data, router]);

  return (
    <div className="max-w-lg mx-auto py-8 px-4">
      <h1 className="text-2xl font-semibold mb-6">Add Contact</h1>

      {state.error && (
        <div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
          {state.error}
        </div>
      )}

      <form action={action} className="space-y-4">
        <div className="grid grid-cols-2 gap-4">
          <Field
            label="First Name"
            name="firstName"
            required
            error={state.fieldErrors?.firstName?.[0]}
          />
          <Field
            label="Last Name"
            name="lastName"
            required
            error={state.fieldErrors?.lastName?.[0]}
          />
        </div>

        <Field
          label="Email"
          name="email"
          type="email"
          required
          error={state.fieldErrors?.email?.[0]}
        />

        <Field label="Phone" name="phone" type="tel" />

        <Field label="Company" name="company" />

        <button
          type="submit"
          disabled={isPending}
          className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-60 flex items-center justify-center gap-2"
        >
          {isPending && <Loader2 className="w-4 h-4 animate-spin" />}
          {isPending ? "Saving..." : "Add Contact"}
        </button>
      </form>
    </div>
  );
}

function Field({
  label,
  name,
  type = "text",
  required,
  error,
}: {
  label: string;
  name: string;
  type?: string;
  required?: boolean;
  error?: string;
}) {
  return (
    <div>
      <label className="block text-sm font-medium text-gray-700 mb-1">
        {label}
        {required && <span className="text-red-500 ml-1">*</span>}
      </label>
      <input
        name={name}
        type={type}
        required={required}
        className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
          error ? "border-red-400 bg-red-50" : "border-gray-300"
        }`}
        aria-describedby={error ? `${name}-error` : undefined}
        aria-invalid={!!error}
      />
      {error && (
        <p id={`${name}-error`} className="mt-1 text-xs text-red-600">
          {error}
        </p>
      )}
    </div>
  );
}

๐ŸŒ 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

Progressive Enhancement

Server Actions work without JavaScript. The form above submits and returns even if JS fails to load โ€” the server re-renders with error state. To preserve form values on error (so users don't retype everything), use defaultValue from previous state:

// Preserve input values after failed submit (progressive enhancement)
export default function NewContactPage() {
  const [state, action, isPending] = useActionState(createContact, initialState);

  return (
    <form action={action}>
      <input
        name="firstName"
        defaultValue={state.values?.firstName ?? ""}  // Re-populate on error
      />
      {/* ... */}
    </form>
  );
}

// In the action, return the submitted values alongside errors:
export async function createContact(
  _prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const raw = Object.fromEntries(formData.entries());

  const parsed = ContactSchema.safeParse(raw);
  if (!parsed.success) {
    return {
      success: false,
      values: raw as Record<string, string>, // return submitted values
      fieldErrors: parsed.error.flatten().fieldErrors as ContactFormState["fieldErrors"],
    };
  }
  // ...
}

useOptimistic: Instant UI with Server Sync

For list operations where you want immediate feedback:

// app/todos/todo-list.tsx
"use client";

import { useOptimistic, useTransition } from "react";
import { toggleTodo, deleteTodo } from "./actions";
import { CheckCircle2, Circle, Trash2, Loader2 } from "lucide-react";

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

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [, startTransition] = useTransition();

  const [optimisticTodos, updateOptimistic] = useOptimistic(
    initialTodos,
    (state: Todo[], update: { type: "toggle" | "delete"; id: string }) => {
      if (update.type === "toggle") {
        return state.map((todo) =>
          todo.id === update.id
            ? { ...todo, completed: !todo.completed }
            : todo
        );
      }
      if (update.type === "delete") {
        return state.filter((todo) => todo.id !== update.id);
      }
      return state;
    }
  );

  const handleToggle = (id: string) => {
    startTransition(async () => {
      updateOptimistic({ type: "toggle", id });
      await toggleTodo(id);
    });
  };

  const handleDelete = (id: string) => {
    startTransition(async () => {
      updateOptimistic({ type: "delete", id });
      await deleteTodo(id);
    });
  };

  return (
    <ul className="divide-y divide-gray-100">
      {optimisticTodos.map((todo) => (
        <li
          key={todo.id}
          className="flex items-center gap-3 py-3 px-4 hover:bg-gray-50 group"
        >
          <button
            onClick={() => handleToggle(todo.id)}
            className="flex-shrink-0 text-gray-400 hover:text-blue-600 transition"
          >
            {todo.completed ? (
              <CheckCircle2 className="w-5 h-5 text-green-500" />
            ) : (
              <Circle className="w-5 h-5" />
            )}
          </button>
          <span
            className={`flex-1 text-sm ${
              todo.completed ? "line-through text-gray-400" : "text-gray-900"
            }`}
          >
            {todo.title}
          </span>
          <button
            onClick={() => handleDelete(todo.id)}
            className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition"
          >
            <Trash2 className="w-4 h-4" />
          </button>
        </li>
      ))}
    </ul>
  );
}
// app/todos/actions.ts
"use server";

import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";

export async function toggleTodo(id: string) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  const todo = await prisma.todo.findFirst({
    where: { id, userId: session.user.id },
    select: { completed: true },
  });

  if (!todo) throw new Error("Not found");

  await prisma.todo.update({
    where: { id },
    data: { completed: !todo.completed },
  });

  revalidatePath("/todos");
}

export async function deleteTodo(id: string) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  await prisma.todo.deleteMany({
    where: { id, userId: session.user.id },
  });

  revalidatePath("/todos");
}

๐Ÿš€ 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

Multi-Step Form with Server Actions

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

import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import { redirect } from "next/navigation";

const Step1Schema = z.object({
  workspaceName: z.string().min(2).max(50),
  industry: z.enum(["saas", "ecommerce", "agency", "finance", "other"]),
});

const Step2Schema = z.object({
  teamSize: z.enum(["solo", "2-10", "11-50", "51-200", "200+"]),
  useCase: z.string().min(10, "Please describe your use case").max(500),
});

type StepState = {
  step: number;
  success: boolean;
  error?: string;
  fieldErrors?: Record<string, string[]>;
  data?: Record<string, string>;
};

export async function submitOnboarding(
  prevState: StepState,
  formData: FormData
): Promise<StepState> {
  const session = await auth();
  if (!session?.user) return { ...prevState, error: "Unauthorized" };

  const currentStep = Number(formData.get("__step") ?? 1);

  if (currentStep === 1) {
    const parsed = Step1Schema.safeParse({
      workspaceName: formData.get("workspaceName"),
      industry: formData.get("industry"),
    });

    if (!parsed.success) {
      return {
        step: 1,
        success: false,
        fieldErrors: parsed.error.flatten().fieldErrors,
        data: prevState.data,
      };
    }

    return {
      step: 2,
      success: false,
      data: { ...prevState.data, ...parsed.data },
    };
  }

  if (currentStep === 2) {
    const parsed = Step2Schema.safeParse({
      teamSize: formData.get("teamSize"),
      useCase: formData.get("useCase"),
    });

    if (!parsed.success) {
      return {
        step: 2,
        success: false,
        fieldErrors: parsed.error.flatten().fieldErrors,
        data: prevState.data,
      };
    }

    // Combine all step data and save
    const allData = { ...prevState.data, ...parsed.data };

    await prisma.workspace.update({
      where: { id: session.user.organizationId },
      data: {
        name: allData.workspaceName,
        industry: allData.industry,
        teamSize: allData.teamSize,
        useCase: allData.useCase,
        onboardedAt: new Date(),
      },
    });

    redirect("/dashboard");
  }

  return prevState;
}
// app/onboarding/page.tsx
"use client";

import { useActionState } from "react";
import { submitOnboarding } from "./actions";

const initialState = { step: 1, success: false };

export default function OnboardingPage() {
  const [state, action, isPending] = useActionState(
    submitOnboarding,
    initialState
  );

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white rounded-xl shadow p-8">
        {/* Progress indicator */}
        <div className="flex gap-2 mb-8">
          {[1, 2].map((s) => (
            <div
              key={s}
              className={`h-1 flex-1 rounded-full transition-colors ${
                s <= state.step ? "bg-blue-600" : "bg-gray-200"
              }`}
            />
          ))}
        </div>

        <form action={action}>
          {/* Hidden step tracker */}
          <input type="hidden" name="__step" value={state.step} />

          {/* Carry forward previous step data as hidden fields */}
          {state.data &&
            Object.entries(state.data).map(([key, value]) => (
              <input key={key} type="hidden" name={`__prev_${key}`} value={value} />
            ))}

          {state.step === 1 && (
            <>
              <h2 className="text-xl font-semibold mb-6">Set up your workspace</h2>

              <div className="space-y-4">
                <div>
                  <label className="block text-sm font-medium mb-1">
                    Workspace name
                  </label>
                  <input
                    name="workspaceName"
                    defaultValue={state.data?.workspaceName ?? ""}
                    className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
                    placeholder="Acme Corp"
                  />
                  {state.fieldErrors?.workspaceName && (
                    <p className="mt-1 text-xs text-red-600">
                      {state.fieldErrors.workspaceName[0]}
                    </p>
                  )}
                </div>

                <div>
                  <label className="block text-sm font-medium mb-1">Industry</label>
                  <select
                    name="industry"
                    defaultValue={state.data?.industry ?? ""}
                    className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
                  >
                    <option value="">Select industry</option>
                    <option value="saas">SaaS</option>
                    <option value="ecommerce">E-commerce</option>
                    <option value="agency">Agency</option>
                    <option value="finance">Finance</option>
                    <option value="other">Other</option>
                  </select>
                </div>
              </div>
            </>
          )}

          {state.step === 2 && (
            <>
              <h2 className="text-xl font-semibold mb-6">Tell us about your team</h2>

              <div className="space-y-4">
                <div>
                  <label className="block text-sm font-medium mb-2">Team size</label>
                  <div className="grid grid-cols-3 gap-2">
                    {["solo", "2-10", "11-50", "51-200", "200+"].map((size) => (
                      <label
                        key={size}
                        className="flex items-center justify-center border rounded-lg p-2 cursor-pointer text-sm has-[:checked]:border-blue-600 has-[:checked]:bg-blue-50 has-[:checked]:text-blue-700"
                      >
                        <input
                          type="radio"
                          name="teamSize"
                          value={size}
                          className="sr-only"
                        />
                        {size}
                      </label>
                    ))}
                  </div>
                </div>

                <div>
                  <label className="block text-sm font-medium mb-1">
                    How will you use this?
                  </label>
                  <textarea
                    name="useCase"
                    rows={4}
                    className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
                    placeholder="Describe your use case..."
                  />
                  {state.fieldErrors?.useCase && (
                    <p className="mt-1 text-xs text-red-600">
                      {state.fieldErrors.useCase[0]}
                    </p>
                  )}
                </div>
              </div>
            </>
          )}

          <button
            type="submit"
            disabled={isPending}
            className="w-full mt-6 py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-60"
          >
            {isPending
              ? "Saving..."
              : state.step === 2
              ? "Complete Setup"
              : "Continue โ†’"}
          </button>
        </form>
      </div>
    </div>
  );
}

File Upload to S3

// app/api/upload/presign/route.ts
// Step 1: Client requests a presigned URL
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { z } from "zod";
import crypto from "crypto";

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

const PresignSchema = z.object({
  fileName: z.string().min(1).max(255),
  contentType: z.string().regex(/^image\/(jpeg|png|webp|gif)$/, {
    message: "Only JPEG, PNG, WebP, and GIF images are allowed",
  }),
  fileSize: z.number().max(5 * 1024 * 1024, "File must be under 5MB"),
});

export async function POST(req: NextRequest) {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await req.json();
  const parsed = PresignSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: parsed.error.issues[0].message },
      { status: 400 }
    );
  }

  const { fileName, contentType, fileSize } = parsed.data;
  const ext = fileName.split(".").pop()?.toLowerCase() ?? "jpg";
  const key = `uploads/${session.user.organizationId}/${crypto.randomUUID()}.${ext}`;

  const presignedUrl = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.UPLOADS_BUCKET!,
      Key: key,
      ContentType: contentType,
      ContentLength: fileSize,
      Metadata: {
        userId: session.user.id,
        originalName: encodeURIComponent(fileName),
      },
    }),
    { expiresIn: 300 } // 5 minutes to upload
  );

  return NextResponse.json({
    presignedUrl,
    key,
    publicUrl: `https://${process.env.UPLOADS_BUCKET}.s3.amazonaws.com/${key}`,
  });
}
// Server Action that handles the metadata after direct S3 upload
// app/profile/actions.ts
"use server";

import { z } from "zod";
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import { revalidatePath } from "next/cache";
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";

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

const AvatarSchema = z.object({
  s3Key: z.string().regex(/^uploads\/[a-f0-9-]+\/[a-f0-9-]+\.(jpg|jpeg|png|webp|gif)$/),
});

export type AvatarState = { success: boolean; error?: string; avatarUrl?: string };

export async function updateAvatar(
  _prev: AvatarState,
  formData: FormData
): Promise<AvatarState> {
  const session = await auth();
  if (!session?.user) return { success: false, error: "Unauthorized" };

  const parsed = AvatarSchema.safeParse({ s3Key: formData.get("s3Key") });
  if (!parsed.success) {
    return { success: false, error: "Invalid file key" };
  }

  // Verify the object actually exists in S3 (user uploaded it)
  try {
    const head = await s3.send(
      new HeadObjectCommand({
        Bucket: process.env.UPLOADS_BUCKET!,
        Key: parsed.data.s3Key,
      })
    );

    // Verify content type
    if (!head.ContentType?.startsWith("image/")) {
      return { success: false, error: "File must be an image" };
    }

    // Verify size
    if ((head.ContentLength ?? 0) > 5 * 1024 * 1024) {
      return { success: false, error: "File exceeds 5MB limit" };
    }
  } catch {
    return { success: false, error: "File not found โ€” upload may have failed" };
  }

  const avatarUrl = `https://${process.env.UPLOADS_BUCKET}.s3.amazonaws.com/${parsed.data.s3Key}`;

  await prisma.user.update({
    where: { id: session.user.id },
    data: { image: avatarUrl },
  });

  revalidatePath("/profile");

  return { success: true, avatarUrl };
}
// components/avatar-upload.tsx โ€” Client component for file upload flow
"use client";

import { useActionState, useRef, useState } from "react";
import { updateAvatar } from "@/app/profile/actions";
import { Upload, Loader2 } from "lucide-react";

export function AvatarUpload({ currentAvatar }: { currentAvatar?: string }) {
  const [preview, setPreview] = useState<string | null>(null);
  const [uploading, setUploading] = useState(false);
  const [s3Key, setS3Key] = useState("");
  const fileRef = useRef<HTMLInputElement>(null);
  const formRef = useRef<HTMLFormElement>(null);
  const [state, action, isPending] = useActionState(updateAvatar, { success: false });

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;

    // Local preview
    setPreview(URL.createObjectURL(file));
    setUploading(true);

    try {
      // Step 1: Get presigned URL
      const presignRes = await fetch("/api/upload/presign", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          fileName: file.name,
          contentType: file.type,
          fileSize: file.size,
        }),
      });

      if (!presignRes.ok) {
        const err = await presignRes.json();
        throw new Error(err.error);
      }

      const { presignedUrl, key } = await presignRes.json();

      // Step 2: Upload directly to S3
      const uploadRes = await fetch(presignedUrl, {
        method: "PUT",
        body: file,
        headers: { "Content-Type": file.type },
      });

      if (!uploadRes.ok) throw new Error("Upload failed");

      setS3Key(key);
    } catch (err) {
      alert(err instanceof Error ? err.message : "Upload failed");
      setPreview(null);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="space-y-4">
      {/* Preview */}
      <div className="flex items-center gap-4">
        <div className="relative w-20 h-20 rounded-full overflow-hidden bg-gray-100 border-2 border-gray-200">
          {(preview ?? currentAvatar) ? (
            <img
              src={preview ?? currentAvatar}
              alt="Avatar"
              className="w-full h-full object-cover"
            />
          ) : (
            <div className="w-full h-full flex items-center justify-center text-gray-400">
              <Upload className="w-6 h-6" />
            </div>
          )}
          {uploading && (
            <div className="absolute inset-0 bg-black/40 flex items-center justify-center">
              <Loader2 className="w-5 h-5 text-white animate-spin" />
            </div>
          )}
        </div>

        <label className="cursor-pointer text-sm text-blue-600 hover:text-blue-700 font-medium">
          {uploading ? "Uploading..." : "Choose photo"}
          <input
            ref={fileRef}
            type="file"
            accept="image/jpeg,image/png,image/webp,image/gif"
            className="sr-only"
            onChange={handleFileChange}
            disabled={uploading}
          />
        </label>
      </div>

      {/* Server Action form (submits s3Key after upload) */}
      {s3Key && (
        <form ref={formRef} action={action}>
          <input type="hidden" name="s3Key" value={s3Key} />
          <button
            type="submit"
            disabled={isPending || uploading}
            className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-60"
          >
            {isPending ? "Saving..." : "Save Photo"}
          </button>
        </form>
      )}

      {state.success && (
        <p className="text-sm text-green-600">Avatar updated successfully</p>
      )}
      {state.error && (
        <p className="text-sm text-red-600">{state.error}</p>
      )}
    </div>
  );
}

Error Handling in Server Actions

// lib/actions/safe-action.ts
import { auth } from "@/auth";

type ActionResult<T> =
  | { success: true; data: T }
  | { success: false; error: string; code?: string };

export function createAction<TInput, TOutput>(
  handler: (input: TInput, userId: string) => Promise<TOutput>
) {
  return async (input: TInput): Promise<ActionResult<TOutput>> => {
    try {
      const session = await auth();
      if (!session?.user) {
        return { success: false, error: "Unauthorized", code: "UNAUTHORIZED" };
      }

      const data = await handler(input, session.user.id);
      return { success: true, data };
    } catch (err) {
      if (err instanceof Error) {
        // Don't expose internal error details to client
        console.error("[Action Error]", err);
        return {
          success: false,
          error:
            process.env.NODE_ENV === "development"
              ? err.message
              : "An unexpected error occurred",
          code: "INTERNAL_ERROR",
        };
      }
      throw err; // Re-throw non-Error types (e.g., redirect())
    }
  };
}

// Usage:
export const archiveContact = createAction(
  async (contactId: string, userId: string) => {
    const contact = await prisma.contact.findFirst({
      where: { id: contactId, workspace: { members: { some: { userId } } } },
    });
    if (!contact) throw new Error("Contact not found");

    return prisma.contact.update({
      where: { id: contactId },
      data: { archivedAt: new Date() },
    });
  }
);

Cost and Timeline Estimates

Feature ScopeTeamTimelineCost Range
Basic form with useActionState1 dev0.5โ€“1 day$200โ€“500
Multi-step onboarding flow1 dev2โ€“3 days$600โ€“1,200
File upload with S3 presigned URLs1 dev1โ€“2 days$400โ€“800
Full form system (validation, optimistic, uploads)1โ€“2 devs1โ€“2 weeks$2,500โ€“5,000
Complex forms (dynamic fields, conditional logic, rich text)2 devs2โ€“4 weeks$5,000โ€“12,000

See Also


Working With Viprasol

Server Actions are now the right way to build forms in Next.js โ€” but getting validation feedback, progressive enhancement, optimistic updates, and file uploads working together requires careful composition. Our team builds full-stack Next.js applications where forms are production-grade: validated server-side, accessible, progressively enhanced, and wired to your data layer correctly.

What we deliver:

  • Server Actions with Zod validation and structured error responses
  • Multi-step forms with state persistence
  • Direct-to-S3 file uploads with presigned URL flow
  • Optimistic UI with automatic rollback on error
  • Accessible form components with proper ARIA attributes

Talk to our team about your Next.js project โ†’

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.