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.
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 Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Basic form with useActionState | 1 dev | 0.5โ1 day | $200โ500 |
| Multi-step onboarding flow | 1 dev | 2โ3 days | $600โ1,200 |
| File upload with S3 presigned URLs | 1 dev | 1โ2 days | $400โ800 |
| Full form system (validation, optimistic, uploads) | 1โ2 devs | 1โ2 weeks | $2,500โ5,000 |
| Complex forms (dynamic fields, conditional logic, rich text) | 2 devs | 2โ4 weeks | $5,000โ12,000 |
See Also
- Next.js App Router Caching Strategies
- Next.js Middleware for Auth and Routing
- React Hook Form with Zod Validation
- React Optimistic Updates with React Query
- SaaS Customer Portal with Stripe Billing
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.
About the Author
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.
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
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.