Gherkin to Code
Turn BDD Gherkin requirements into real React 19 + Next.js 15 implementation. Each section shows the Gherkin scenario first, then the exact code that satisfies it.
The example project is a Cloud Architect Profile Form -- a 6-step multi-step form with file uploads, schema validation, server actions, and accessible UI.
Gherkin to Zod Schema
Gherkin validation scenarios translate directly into zod schemas. Each Then they should see "..." maps to a zod error message.
Gherkin
Feature: Profile Validation Schema
Scenario: Full name is required and has minimum length
Given the architect submits step 1 with an empty "Full Name" field
Then they should see "Full name is required"
Scenario: Full name must be at least 2 characters
Given the architect types "A" in the "Full Name" field
When they click "Next"
Then they should see "Name must be at least 2 characters"
Scenario: Email must be valid format
Given the architect types "not-an-email" in the "Email" field
When they click "Next"
Then they should see "Please enter a valid email address"
Scenario: LinkedIn URL must point to linkedin.com
Given the architect types "https://twitter.com/someone" in the "LinkedIn URL"
When they click "Next"
Then they should see "Must be a valid LinkedIn profile URL"
Scenario: Years of experience must be between 0 and 50
Given the architect types "-3" in the "Years of Experience" field
When they click "Next"
Then they should see "Must be between 0 and 50 years"
Scenario: At least one cloud platform is required
Given the architect checks no cloud platforms on step 4
When they click "Next"
Then they should see "Select at least one cloud platform"
Scenario: Job end date must be after start date
Given the architect sets start date "2025-06-01" and end date "2024-01-01"
When they click "Next"
Then they should see "End date must be after start date"
Scenario: Bio cannot exceed 1000 characters
Given the architect types 1001 characters in the "Bio" field
Then they should see "Bio must be 1000 characters or fewer"
Scenario: Profile photo must be an image under 5MB
Given the architect selects a 15MB PNG for "Profile Photo"
Then they should see "File must be under 5MB"
Scenario: Profile photo rejects non-image files
Given the architect selects a .exe file for "Profile Photo"
Then they should see "Only JPEG, PNG, and WebP files are accepted"Code
// lib/schemas/architect-profile.ts
import { z } from "zod";
// ── Step 1: Personal Info ──────────────────────────────
export const personalInfoSchema = z.object({
fullName: z
.string()
.min(1, "Full name is required")
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.min(1, "Email is required")
.email("Please enter a valid email address"),
phone: z.string().optional(),
linkedinUrl: z
.string()
.url("Must be a valid URL")
.refine(
(url) => url.includes("linkedin.com/"),
"Must be a valid LinkedIn profile URL"
)
.or(z.literal("")),
});
// ── Step 2: Experience ─────────────────────────────────
export const experienceSchema = z.object({
yearsOfExperience: z
.number({ invalid_type_error: "Must be a number" })
.int("Must be a whole number")
.min(0, "Must be between 0 and 50 years")
.max(50, "Must be between 0 and 50 years"),
currentRole: z.string().min(1, "Current role is required"),
certifications: z
.array(z.string())
.min(0),
bio: z
.string()
.max(1000, "Bio must be 1000 characters or fewer")
.optional(),
});
// ── Step 3: Job History ────────────────────────────────
const jobEntrySchema = z
.object({
company: z.string().min(1, "Company name is required"),
role: z.string().min(1, "Role is required"),
startDate: z.string().min(1, "Start date is required"),
endDate: z.string().optional(),
isCurrent: z.boolean().default(false),
description: z.string().max(500).optional(),
})
.refine(
(data) => {
if (data.isCurrent || !data.endDate) return true;
return new Date(data.endDate) > new Date(data.startDate);
},
{ message: "End date must be after start date", path: ["endDate"] }
);
export const jobHistorySchema = z.object({
jobs: z.array(jobEntrySchema).min(1, "Add at least one position"),
});
// ── Step 4: Skills ─────────────────────────────────────
export const skillsSchema = z.object({
cloudPlatforms: z
.array(z.enum(["aws", "azure", "gcp"]))
.min(1, "Select at least one cloud platform"),
specialties: z.array(z.string()).min(1, "Select at least one specialty"),
awsProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),
azureProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),
gcpProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),
});
// ── Step 5: Uploads ────────────────────────────────────
const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_DIAGRAM_COUNT = 10;
const imageFileSchema = z
.instanceof(File)
.refine(
(file) => file.size <= MAX_FILE_SIZE,
"File must be under 5MB"
)
.refine(
(file) => ACCEPTED_IMAGE_TYPES.includes(file.type),
"Only JPEG, PNG, and WebP files are accepted"
);
export const uploadsSchema = z.object({
profilePhoto: imageFileSchema.optional(),
architectureDiagrams: z
.array(imageFileSchema)
.max(MAX_DIAGRAM_COUNT, `Maximum ${MAX_DIAGRAM_COUNT} files allowed`)
.optional(),
siteScreenshots: z
.array(imageFileSchema)
.max(MAX_DIAGRAM_COUNT, `Maximum ${MAX_DIAGRAM_COUNT} files allowed`)
.optional(),
});
// ── Combined Schema ────────────────────────────────────
export const architectProfileSchema = personalInfoSchema
.merge(experienceSchema)
.merge(jobHistorySchema)
.merge(skillsSchema)
.merge(uploadsSchema);
export type ArchitectProfile = z.infer<typeof architectProfileSchema>;
export type PersonalInfo = z.infer<typeof personalInfoSchema>;
export type Experience = z.infer<typeof experienceSchema>;
export type JobHistory = z.infer<typeof jobHistorySchema>;
export type Skills = z.infer<typeof skillsSchema>;
export type Uploads = z.infer<typeof uploadsSchema>;How it maps: Every Gherkin Then they should see "..." becomes the exact string in a zod .min(), .max(), .refine(), or .email() message. The schema IS the spec.
Gherkin to react-hook-form Setup
Gherkin field-interaction scenarios define how register, useFieldArray, and zodResolver wire up.
Gherkin
Feature: Form Field Registration
Scenario: Personal info fields are wired to form state
Given the architect is on step 1 "Personal Info"
When they type "Jane Doe" in "Full Name"
And they type "jane@example.com" in "Email"
And they type "+1-555-0100" in "Phone"
Then the form state should contain those values
Scenario: Certification multi-select tracks checked items
Given the architect is on step 2 "Experience"
When they check "AWS Solutions Architect Professional"
And they check "Terraform Associate"
Then the certifications array should contain both values
Scenario: Job history supports adding and removing entries
Given the architect is on step 3 "Job History"
And they have 2 job entries
When they click "Remove" on the first entry
Then only 1 job entry should remain
When they click "Add Another Position"
Then 2 job entries should appear again
Scenario: Validation runs on blur for immediate feedback
Given the architect is on step 1 "Personal Info"
When they type "bad" in the "Email" field
And they move focus to the next field
Then they should see "Please enter a valid email address"Code
// components/profile-form/personal-info-step.tsx
"use client";
import { useFormContext } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
export function PersonalInfoStep() {
const {
register,
formState: { errors },
} = useFormContext<ArchitectProfile>();
return (
<fieldset>
<legend className="text-lg font-semibold">Personal Information</legend>
<div className="space-y-4">
<div>
<Label htmlFor="fullName">
Full Name <span aria-hidden="true">*</span>
</Label>
<Input
id="fullName"
aria-required="true"
aria-invalid={!!errors.fullName}
aria-describedby={errors.fullName ? "fullName-error" : undefined}
{...register("fullName")}
/>
{errors.fullName && (
<p id="fullName-error" role="alert" className="text-sm text-destructive mt-1">
{errors.fullName.message}
</p>
)}
</div>
<div>
<Label htmlFor="email">
Email <span aria-hidden="true">*</span>
</Label>
<Input
id="email"
type="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email && (
<p id="email-error" role="alert" className="text-sm text-destructive mt-1">
{errors.email.message}
</p>
)}
</div>
<div>
<Label htmlFor="phone">Phone</Label>
<Input id="phone" type="tel" {...register("phone")} />
</div>
<div>
<Label htmlFor="linkedinUrl">LinkedIn URL</Label>
<Input
id="linkedinUrl"
type="url"
placeholder="https://linkedin.com/in/your-profile"
aria-invalid={!!errors.linkedinUrl}
aria-describedby={errors.linkedinUrl ? "linkedin-error" : undefined}
{...register("linkedinUrl")}
/>
{errors.linkedinUrl && (
<p id="linkedin-error" role="alert" className="text-sm text-destructive mt-1">
{errors.linkedinUrl.message}
</p>
)}
</div>
</div>
</fieldset>
);
}// components/profile-form/job-history-step.tsx
"use client";
import { useFormContext, useFieldArray } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
const MAX_JOBS = 10;
export function JobHistoryStep() {
const {
register,
control,
formState: { errors },
watch,
} = useFormContext<ArchitectProfile>();
const { fields, append, remove } = useFieldArray({
control,
name: "jobs",
});
return (
<fieldset>
<legend className="text-lg font-semibold">Job History</legend>
{fields.map((field, index) => {
const isCurrent = watch(`jobs.${index}.isCurrent`);
return (
<div key={field.id} className="border rounded-lg p-4 space-y-3 mb-4">
<div className="flex justify-between items-center">
<h3 className="font-medium">Position {index + 1}</h3>
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(index)}
>
Remove
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor={`jobs.${index}.company`}>Company *</Label>
<Input
id={`jobs.${index}.company`}
aria-required="true"
{...register(`jobs.${index}.company`)}
/>
{errors.jobs?.[index]?.company && (
<p role="alert" className="text-sm text-destructive mt-1">
{errors.jobs[index].company.message}
</p>
)}
</div>
<div>
<Label htmlFor={`jobs.${index}.role`}>Role *</Label>
<Input
id={`jobs.${index}.role`}
aria-required="true"
{...register(`jobs.${index}.role`)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor={`jobs.${index}.startDate`}>Start Date *</Label>
<Input
id={`jobs.${index}.startDate`}
type="date"
aria-required="true"
{...register(`jobs.${index}.startDate`)}
/>
</div>
<div>
<Label htmlFor={`jobs.${index}.endDate`}>End Date</Label>
<Input
id={`jobs.${index}.endDate`}
type="date"
disabled={isCurrent}
{...register(`jobs.${index}.endDate`)}
/>
{errors.jobs?.[index]?.endDate && (
<p role="alert" className="text-sm text-destructive mt-1">
{errors.jobs[index].endDate.message}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id={`jobs.${index}.isCurrent`}
{...register(`jobs.${index}.isCurrent`)}
/>
<Label htmlFor={`jobs.${index}.isCurrent`}>
I currently work here
</Label>
</div>
<div>
<Label htmlFor={`jobs.${index}.description`}>Description</Label>
<Textarea
id={`jobs.${index}.description`}
rows={3}
{...register(`jobs.${index}.description`)}
/>
</div>
</div>
);
})}
{fields.length < MAX_JOBS && (
<Button
type="button"
variant="outline"
onClick={() =>
append({
company: "",
role: "",
startDate: "",
endDate: "",
isCurrent: false,
description: "",
})
}
>
Add Another Position
</Button>
)}
{errors.jobs?.root && (
<p role="alert" className="text-sm text-destructive mt-2">
{errors.jobs.root.message}
</p>
)}
</fieldset>
);
}// components/profile-form/profile-form-provider.tsx
"use client";
import { useForm, FormProvider } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
architectProfileSchema,
type ArchitectProfile,
} from "@/lib/schemas/architect-profile";
const defaultValues: Partial<ArchitectProfile> = {
fullName: "",
email: "",
phone: "",
linkedinUrl: "",
yearsOfExperience: 0,
currentRole: "",
certifications: [],
bio: "",
jobs: [{ company: "", role: "", startDate: "", isCurrent: false }],
cloudPlatforms: [],
specialties: [],
};
interface ProfileFormProviderProps {
children: React.ReactNode;
}
export function ProfileFormProvider({ children }: ProfileFormProviderProps) {
const methods = useForm<ArchitectProfile>({
resolver: zodResolver(architectProfileSchema),
defaultValues,
mode: "onBlur", // ← Gherkin: "move focus to next field" triggers validation
});
return <FormProvider {...methods}>{children}</FormProvider>;
}How it maps:
When they type "..." in "..."→register("fieldName")on the inputThen the form state should contain those values→ react-hook-form tracks values automatically via uncontrolled inputsWhen they click "Add Another Position"→useFieldArray.append()When they click "Remove"→useFieldArray.remove(index)And they move focus to the next field→mode: "onBlur"inuseForm
Gherkin to Zustand Multi-Step Store
Gherkin navigation scenarios define the step state machine -- which step is active, which are completed, and how navigation is constrained.
Gherkin
Feature: Multi-Step Form State
Scenario: Initial state shows step 1
Given the architect opens the profile form
Then the current step should be 1
And steps 2 through 6 should be locked
And the "Back" button should be hidden
Scenario: Completing step 1 unlocks step 2
Given the architect fills all required fields on step 1
When they click "Next"
Then the current step should be 2
And step 1 should show a checkmark
And the "Back" button should be visible
Scenario: Navigating back preserves completed status
Given the architect has completed steps 1 through 3
And they are on step 4
When they click "Back"
Then the current step should be 3
And steps 1 through 3 should still show checkmarks
Scenario: Clicking a completed step jumps to it
Given the architect has completed steps 1 through 4
And they are on step 5
When they click step 2 in the progress indicator
Then the current step should be 2
Scenario: Cannot jump ahead past incomplete steps
Given the architect is on step 2
And step 3 has not been completed
When they click step 4 in the progress indicator
Then the current step should remain 2
Scenario: Review step shows all entered data
Given the architect has completed steps 1 through 5
When they arrive at step 6 "Review"
Then they should see a summary of all entered data
And each section should have an "Edit" linkCode
// stores/profile-form-store.ts
import { create } from "zustand";
export type StepStatus = "upcoming" | "current" | "completed";
export const STEPS = [
{ id: 1, label: "Personal Info" },
{ id: 2, label: "Experience" },
{ id: 3, label: "Job History" },
{ id: 4, label: "Skills" },
{ id: 5, label: "Uploads" },
{ id: 6, label: "Review & Submit" },
] as const;
export const TOTAL_STEPS = STEPS.length;
interface ProfileFormState {
currentStep: number;
completedSteps: Set<number>;
// Actions
goToNext: () => void;
goToPrevious: () => void;
goToStep: (step: number) => void;
markCompleted: (step: number) => void;
getStepStatus: (step: number) => StepStatus;
canNavigateTo: (step: number) => boolean;
reset: () => void;
}
export const useProfileFormStore = create<ProfileFormState>((set, get) => ({
currentStep: 1,
completedSteps: new Set<number>(),
goToNext: () => {
const { currentStep } = get();
if (currentStep < TOTAL_STEPS) {
set((state) => {
const completed = new Set(state.completedSteps);
completed.add(currentStep);
return { currentStep: currentStep + 1, completedSteps: completed };
});
}
},
goToPrevious: () => {
const { currentStep } = get();
if (currentStep > 1) {
set({ currentStep: currentStep - 1 });
}
},
goToStep: (step: number) => {
const { canNavigateTo } = get();
if (canNavigateTo(step)) {
set({ currentStep: step });
}
},
markCompleted: (step: number) => {
set((state) => {
const completed = new Set(state.completedSteps);
completed.add(step);
return { completedSteps: completed };
});
},
getStepStatus: (step: number): StepStatus => {
const { currentStep, completedSteps } = get();
if (step === currentStep) return "current";
if (completedSteps.has(step)) return "completed";
return "upcoming";
},
canNavigateTo: (step: number): boolean => {
const { completedSteps, currentStep } = get();
// Can always go to current step or completed steps
if (step === currentStep) return true;
if (completedSteps.has(step)) return true;
// Can go to next step if all previous steps are completed
if (step === currentStep + 1) return true;
return false;
},
reset: () => set({ currentStep: 1, completedSteps: new Set() }),
}));// components/profile-form/step-indicator.tsx
"use client";
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import {
STEPS,
useProfileFormStore,
type StepStatus,
} from "@/stores/profile-form-store";
export function StepIndicator() {
const { currentStep, getStepStatus, canNavigateTo, goToStep } =
useProfileFormStore();
return (
<nav aria-label="Form progress">
<p className="sr-only">
Step {currentStep} of {STEPS.length}
</p>
<ol className="flex items-center gap-2">
{STEPS.map((step) => {
const status = getStepStatus(step.id);
const navigable = canNavigateTo(step.id);
return (
<li key={step.id} className="flex items-center gap-2">
<button
type="button"
onClick={() => goToStep(step.id)}
disabled={!navigable}
aria-current={status === "current" ? "step" : undefined}
aria-label={`Step ${step.id}: ${step.label}${
status === "completed" ? " (completed)" : ""
}`}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors",
status === "current" &&
"bg-primary text-primary-foreground",
status === "completed" &&
"bg-primary/20 text-primary hover:bg-primary/30",
status === "upcoming" &&
"bg-muted text-muted-foreground cursor-not-allowed"
)}
>
{status === "completed" ? (
<Check className="h-4 w-4" aria-hidden="true" />
) : (
step.id
)}
</button>
<span
className={cn(
"hidden text-sm sm:inline",
status === "current" && "font-semibold",
status === "upcoming" && "text-muted-foreground"
)}
>
{step.label}
</span>
{step.id < STEPS.length && (
<div
className={cn(
"hidden h-px w-8 sm:block",
status === "completed" ? "bg-primary" : "bg-muted"
)}
aria-hidden="true"
/>
)}
</li>
);
})}
</ol>
</nav>
);
}// components/profile-form/step-navigation.tsx
"use client";
import { useFormContext } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { useProfileFormStore, TOTAL_STEPS } from "@/stores/profile-form-store";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
// Maps step number to the fields that must validate before advancing
const STEP_FIELDS: Record<number, (keyof ArchitectProfile)[]> = {
1: ["fullName", "email", "phone", "linkedinUrl"],
2: ["yearsOfExperience", "currentRole", "certifications", "bio"],
3: ["jobs"],
4: ["cloudPlatforms", "specialties"],
5: ["profilePhoto", "architectureDiagrams", "siteScreenshots"],
};
export function StepNavigation() {
const { currentStep, goToNext, goToPrevious } = useProfileFormStore();
const { trigger } = useFormContext<ArchitectProfile>();
const handleNext = async () => {
const fields = STEP_FIELDS[currentStep];
const isValid = await trigger(fields);
if (isValid) {
goToNext();
}
};
const isFirstStep = currentStep === 1;
const isLastStep = currentStep === TOTAL_STEPS;
return (
<div className="flex justify-between pt-6">
{!isFirstStep ? (
<Button type="button" variant="outline" onClick={goToPrevious}>
Back
</Button>
) : (
<div /> // Spacer
)}
{!isLastStep ? (
<Button type="button" onClick={handleNext}>
Next
</Button>
) : (
<Button type="submit">Submit Profile</Button>
)}
</div>
);
}How it maps:
Then the current step should be 1→currentStepstate in zustandAnd steps 2 through 6 should be locked→canNavigateTo()returns falseThen step 1 should show a checkmark→getStepStatus(1) === "completed"renders<Check />When they click step 2 in the progress indicator→goToStep(2)When they click "Next"→ validates current step fields withtrigger(), thengoToNext()
Gherkin to Server Actions
Gherkin submission scenarios define the server action contract -- what it receives, what it returns, and how it handles errors.
Gherkin
Feature: Profile Submission Server Action
Scenario: Successful profile creation
Given the architect submits a valid complete profile
Then the server should receive all form fields and files
And a new profile record should be created
And the response should contain the profile ID
And the architect should be redirected to "/profile/me"
Scenario: Server rejects duplicate email
Given the architect submits a profile with email "taken@example.com"
And that email already exists in the database
Then the server should return field error "This email is already registered"
And the error should be associated with the "email" field
Scenario: Server validates file types on backend
Given an attacker bypasses client-side validation
And submits a PHP file disguised as a JPEG
Then the server should reject the file
And return "Invalid file type"
Scenario: Server handles unexpected errors gracefully
Given the database is temporarily unavailable
When the architect submits a profile
Then the server should return "Something went wrong. Please try again."
And no partial data should be persistedCode
// lib/actions/create-profile.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
import { architectProfileSchema } from "@/lib/schemas/architect-profile";
// Server-side only schema -- stricter than client
const serverFileSchema = z
.instanceof(File)
.refine((file) => file.size <= 5 * 1024 * 1024, "File must be under 5MB")
.refine(
(file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
"Invalid file type"
);
export type ProfileActionState = {
success: boolean;
message: string;
fieldErrors: Record<string, string>;
};
const initialState: ProfileActionState = {
success: false,
message: "",
fieldErrors: {},
};
export async function createProfile(
prevState: ProfileActionState,
formData: FormData
): Promise<ProfileActionState> {
try {
// ── 1. Parse text fields from FormData ───────────
const rawData = {
fullName: formData.get("fullName") as string,
email: formData.get("email") as string,
phone: formData.get("phone") as string,
linkedinUrl: formData.get("linkedinUrl") as string,
yearsOfExperience: Number(formData.get("yearsOfExperience")),
currentRole: formData.get("currentRole") as string,
certifications: formData.getAll("certifications") as string[],
bio: formData.get("bio") as string,
jobs: JSON.parse(formData.get("jobs") as string),
cloudPlatforms: formData.getAll("cloudPlatforms") as string[],
specialties: formData.getAll("specialties") as string[],
awsProficiency: formData.get("awsProficiency") as string,
azureProficiency: formData.get("azureProficiency") as string,
gcpProficiency: formData.get("gcpProficiency") as string,
};
// ── 2. Validate text fields with zod ─────────────
const textResult = architectProfileSchema
.omit({ profilePhoto: true, architectureDiagrams: true, siteScreenshots: true })
.safeParse(rawData);
if (!textResult.success) {
const fieldErrors: Record<string, string> = {};
for (const issue of textResult.error.issues) {
const path = issue.path.join(".");
fieldErrors[path] = issue.message;
}
return { success: false, message: "", fieldErrors };
}
// ── 3. Validate files server-side (dual validation) ──
const profilePhoto = formData.get("profilePhoto") as File | null;
if (profilePhoto && profilePhoto.size > 0) {
const fileResult = serverFileSchema.safeParse(profilePhoto);
if (!fileResult.success) {
return {
success: false,
message: "",
fieldErrors: { profilePhoto: fileResult.error.issues[0].message },
};
}
}
const diagrams = formData.getAll("architectureDiagrams") as File[];
for (const diagram of diagrams) {
if (diagram.size > 0) {
const fileResult = serverFileSchema.safeParse(diagram);
if (!fileResult.success) {
return {
success: false,
message: "",
fieldErrors: {
architectureDiagrams: fileResult.error.issues[0].message,
},
};
}
}
}
// ── 4. Check for duplicate email ─────────────────
const existingProfile = await findProfileByEmail(textResult.data.email);
if (existingProfile) {
return {
success: false,
message: "",
fieldErrors: { email: "This email is already registered" },
};
}
// ── 5. Upload files to storage ───────────────────
const photoUrl = profilePhoto?.size
? await uploadToStorage(profilePhoto, "profiles")
: null;
const diagramUrls = await Promise.all(
diagrams
.filter((f) => f.size > 0)
.map((f) => uploadToStorage(f, "diagrams"))
);
// ── 6. Create profile record ─────────────────────
const profile = await createProfileRecord({
...textResult.data,
photoUrl,
diagramUrls,
});
redirect(`/profile/${profile.id}`);
} catch (error) {
// redirect() throws a NEXT_REDIRECT error -- rethrow it
if (error instanceof Error && error.message === "NEXT_REDIRECT") {
throw error;
}
console.error("Profile creation failed:", error);
return {
success: false,
message: "Something went wrong. Please try again.",
fieldErrors: {},
};
}
}
// Placeholder data functions -- replace with your actual DB/storage layer
async function findProfileByEmail(email: string) {
// db.profile.findUnique({ where: { email } })
return null;
}
async function uploadToStorage(file: File, folder: string): Promise<string> {
// Upload to S3/Cloudflare R2/etc. and return URL
return `https://storage.example.com/${folder}/${file.name}`;
}
async function createProfileRecord(data: Record<string, unknown>) {
// db.profile.create({ data })
return { id: "new-profile-id" };
}How it maps:
Then the server should receive all form fields and files→FormDataparsing in the actionThen the server should return field error "..."→fieldErrorsin the return typeAnd the error should be associated with the "email" field→{ email: "..." }infieldErrorsThen the server should reject the file→ dual validation withserverFileSchemaThen the server should return "Something went wrong..."→ catch block returns generic message
Gherkin to useActionState Pending and Error UX
Gherkin scenarios for loading states, error display, and recovery define how useActionState wires into the UI.
Gherkin
Feature: Submission UX with useActionState
Scenario: Submit button shows loading state
Given the architect clicks "Submit Profile"
Then the button text should change to "Submitting..."
And the button should be disabled
And a spinner should appear
Scenario: Field errors navigate to the correct step
Given the server returns an error on the "email" field
Then the form should navigate to step 1
And the "Email" field should be highlighted with an error
And the screen reader should announce the error
Scenario: Generic server error shows toast notification
Given the server returns "Something went wrong. Please try again."
Then a toast notification should appear with the error message
And the submit button should be re-enabled
And all entered data should be preserved
Scenario: Successful submission shows success state
Given the server creates the profile successfully
Then the architect should see "Profile created successfully!"
And they should be redirected to their profile pageCode
// components/profile-form/submit-step.tsx
"use client";
import { useActionState, useEffect, useRef } from "react";
import { useFormContext } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { createProfile, type ProfileActionState } from "@/lib/actions/create-profile";
import { useProfileFormStore, STEPS } from "@/stores/profile-form-store";
import { useToast } from "@/hooks/use-toast";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
// Map field names to step numbers for error navigation
const FIELD_TO_STEP: Record<string, number> = {
fullName: 1, email: 1, phone: 1, linkedinUrl: 1,
yearsOfExperience: 2, currentRole: 2, certifications: 2, bio: 2,
jobs: 3,
cloudPlatforms: 4, specialties: 4,
profilePhoto: 5, architectureDiagrams: 5, siteScreenshots: 5,
};
const initialState: ProfileActionState = {
success: false,
message: "",
fieldErrors: {},
};
export function SubmitStep() {
const [state, formAction, isPending] = useActionState(createProfile, initialState);
const { getValues, setError } = useFormContext<ArchitectProfile>();
const { goToStep } = useProfileFormStore();
const { toast } = useToast();
const errorAnnouncerRef = useRef<HTMLDivElement>(null);
// Handle server-returned field errors
useEffect(() => {
if (!state.fieldErrors || Object.keys(state.fieldErrors).length === 0) return;
// Set errors on react-hook-form fields
const firstErrorField = Object.keys(state.fieldErrors)[0];
for (const [field, message] of Object.entries(state.fieldErrors)) {
setError(field as keyof ArchitectProfile, { message });
}
// Navigate to the step containing the first error
const targetStep = FIELD_TO_STEP[firstErrorField];
if (targetStep) {
goToStep(targetStep);
}
}, [state.fieldErrors, setError, goToStep]);
// Handle generic server errors
useEffect(() => {
if (state.message && !state.success) {
toast({
variant: "destructive",
title: "Submission failed",
description: state.message,
});
}
}, [state.message, state.success, toast]);
// Build FormData from react-hook-form values for server action
const handleSubmit = () => {
const values = getValues();
const formData = new FormData();
// Text fields
formData.set("fullName", values.fullName);
formData.set("email", values.email);
formData.set("phone", values.phone ?? "");
formData.set("linkedinUrl", values.linkedinUrl ?? "");
formData.set("yearsOfExperience", String(values.yearsOfExperience));
formData.set("currentRole", values.currentRole);
formData.set("bio", values.bio ?? "");
formData.set("jobs", JSON.stringify(values.jobs));
// Multi-value fields
for (const cert of values.certifications) {
formData.append("certifications", cert);
}
for (const platform of values.cloudPlatforms) {
formData.append("cloudPlatforms", platform);
}
for (const specialty of values.specialties) {
formData.append("specialties", specialty);
}
// Files
if (values.profilePhoto) {
formData.set("profilePhoto", values.profilePhoto);
}
if (values.architectureDiagrams) {
for (const diagram of values.architectureDiagrams) {
formData.append("architectureDiagrams", diagram);
}
}
if (values.siteScreenshots) {
for (const screenshot of values.siteScreenshots) {
formData.append("siteScreenshots", screenshot);
}
}
formAction(formData);
};
const values = getValues();
return (
<div>
<h2 className="text-lg font-semibold mb-4">Review Your Profile</h2>
{/* ── Summary sections with edit links ── */}
<div className="space-y-4">
<ReviewSection
title="Personal Info"
step={1}
items={[
{ label: "Name", value: values.fullName },
{ label: "Email", value: values.email },
{ label: "Phone", value: values.phone },
{ label: "LinkedIn", value: values.linkedinUrl },
]}
/>
<ReviewSection
title="Experience"
step={2}
items={[
{ label: "Years", value: String(values.yearsOfExperience) },
{ label: "Role", value: values.currentRole },
{ label: "Certifications", value: values.certifications.join(", ") },
]}
/>
<ReviewSection
title="Job History"
step={3}
items={values.jobs.map((job) => ({
label: job.company,
value: job.role,
}))}
/>
<ReviewSection
title="Skills"
step={4}
items={[
{ label: "Platforms", value: values.cloudPlatforms.join(", ") },
{ label: "Specialties", value: values.specialties.join(", ") },
]}
/>
<ReviewSection
title="Uploads"
step={5}
items={[
{
label: "Profile Photo",
value: values.profilePhoto ? "Uploaded" : "None",
},
{
label: "Diagrams",
value: `${values.architectureDiagrams?.length ?? 0} files`,
},
]}
/>
</div>
{/* Screen reader error announcer */}
<div ref={errorAnnouncerRef} role="alert" aria-live="assertive" className="sr-only" />
{/* Submit button with pending state */}
<div className="pt-6">
<Button
type="button"
onClick={handleSubmit}
disabled={isPending}
className="w-full"
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
Submitting...
</>
) : (
"Submit Profile"
)}
</Button>
</div>
</div>
);
}
// ── Review section with edit link ──────────────────────
function ReviewSection({
title,
step,
items,
}: {
title: string;
step: number;
items: { label: string; value?: string }[];
}) {
const { goToStep } = useProfileFormStore();
return (
<div className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h3 className="font-medium">{title}</h3>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => goToStep(step)}
>
Edit
</Button>
</div>
<dl className="grid grid-cols-2 gap-2 text-sm">
{items.map((item) => (
<div key={item.label}>
<dt className="text-muted-foreground">{item.label}</dt>
<dd>{item.value || "—"}</dd>
</div>
))}
</dl>
</div>
);
}How it maps:
Then the button text should change to "Submitting..."→isPendingfromuseActionStateAnd a spinner should appear→<Loader2 className="animate-spin" />Then the form should navigate to step 1→FIELD_TO_STEPlookup +goToStep()And the screen reader should announce the error→role="alert"andaria-live="assertive"Then a toast notification should appear→useToast()in the error effect
Gherkin to File Upload Handling
Gherkin upload scenarios define preview, validation, drag-and-drop, and removal behavior.
Gherkin
Feature: File Upload Component
Scenario: Architect uploads a profile photo and sees preview
Given the architect is on step 5 "Uploads"
When they select a JPEG file "headshot.jpg" (2MB) for "Profile Photo"
Then a thumbnail preview of "headshot.jpg" should appear
And the file name "headshot.jpg" should be displayed
And the file size "2 MB" should be displayed
Scenario: Drag and drop into upload zone
Given the architect drags a PNG file over the "Architecture Diagrams" zone
Then the drop zone border should turn blue
And the text should change to "Drop file here"
When they drop the file
Then a thumbnail preview should appear
And the drop zone should return to its default state
Scenario: Removing an uploaded file
Given the architect has uploaded 3 architecture diagrams
When they click the remove button on "diagram-2.png"
Then "diagram-2.png" should be removed from the preview list
And 2 thumbnails should remain
And the "Architecture Diagrams" file count should show "2 files"
Scenario: Client-side validation rejects oversized file
Given the architect selects a 15MB file for "Profile Photo"
Then the file should not be added to the preview
And they should see "File must be under 5MB"
Scenario: Client-side validation rejects wrong file type
Given the architect selects a .pdf file for "Profile Photo"
Then the file should not be added to the preview
And they should see "Only JPEG, PNG, and WebP files are accepted"
Scenario: Maximum file count enforced
Given the architect has uploaded 10 architecture diagrams
When they try to add another file
Then the file input should be disabled
And they should see "Maximum 10 files allowed"Code
// components/profile-form/file-upload.tsx
"use client";
import { useCallback, useState, useRef } from "react";
import { useFormContext, useController } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { X, Upload } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const MAX_SIZE = 5 * 1024 * 1024;
interface FileUploadProps {
name: "profilePhoto" | "architectureDiagrams" | "siteScreenshots";
label: string;
multiple?: boolean;
maxFiles?: number;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function FileUpload({
name,
label,
multiple = false,
maxFiles = 10,
}: FileUploadProps) {
const { control, setError, clearErrors } = useFormContext<ArchitectProfile>();
const { field } = useController({ control, name });
const [isDragging, setIsDragging] = useState(false);
const [previews, setPreviews] = useState<{ file: File; url: string }[]>([]);
const [clientError, setClientError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const files: File[] = multiple
? (field.value as File[]) ?? []
: field.value
? [field.value as File]
: [];
const isMaxReached = multiple && files.length >= maxFiles;
const validateFile = useCallback(
(file: File): string | null => {
if (!ACCEPTED_TYPES.includes(file.type)) {
return "Only JPEG, PNG, and WebP files are accepted";
}
if (file.size > MAX_SIZE) {
return "File must be under 5MB";
}
if (multiple && files.length >= maxFiles) {
return `Maximum ${maxFiles} files allowed`;
}
return null;
},
[files.length, maxFiles, multiple]
);
const addFiles = useCallback(
(newFiles: FileList | File[]) => {
setClientError(null);
clearErrors(name);
const fileArray = Array.from(newFiles);
const validFiles: File[] = [];
for (const file of fileArray) {
const error = validateFile(file);
if (error) {
setClientError(error);
return; // Stop on first error
}
validFiles.push(file);
}
if (validFiles.length === 0) return;
// Create preview URLs
const newPreviews = validFiles.map((file) => ({
file,
url: URL.createObjectURL(file),
}));
if (multiple) {
const updated = [...files, ...validFiles];
field.onChange(updated);
setPreviews((prev) => [...prev, ...newPreviews]);
} else {
// Revoke old preview
previews.forEach((p) => URL.revokeObjectURL(p.url));
field.onChange(validFiles[0]);
setPreviews(newPreviews.slice(0, 1));
}
},
[field, files, multiple, name, clearErrors, validateFile, previews]
);
const removeFile = useCallback(
(index: number) => {
URL.revokeObjectURL(previews[index].url);
setClientError(null);
if (multiple) {
const updated = files.filter((_, i) => i !== index);
field.onChange(updated);
setPreviews((prev) => prev.filter((_, i) => i !== index));
} else {
field.onChange(undefined);
setPreviews([]);
}
// Reset input so the same file can be re-selected
if (inputRef.current) inputRef.current.value = "";
},
[field, files, multiple, previews]
);
// ── Drag and drop handlers ──
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files);
}
},
[addFiles]
);
return (
<div className="space-y-3">
<label className="text-sm font-medium">{label}</label>
{/* Drop zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !isMaxReached && inputRef.current?.click()}
role="button"
tabIndex={0}
aria-label={`Upload ${label}`}
aria-disabled={isMaxReached}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!isMaxReached) inputRef.current?.click();
}
}}
className={cn(
"flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer",
isDragging && "border-primary bg-primary/5",
!isDragging && "border-muted-foreground/25 hover:border-muted-foreground/50",
isMaxReached && "cursor-not-allowed opacity-50"
)}
>
<Upload className="h-8 w-8 text-muted-foreground mb-2" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
{isDragging
? "Drop file here"
: isMaxReached
? `Maximum ${maxFiles} files allowed`
: "Click or drag files here"}
</p>
<p className="text-xs text-muted-foreground mt-1">
JPEG, PNG, or WebP up to 5MB
</p>
</div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple={multiple}
disabled={isMaxReached}
onChange={(e) => e.target.files && addFiles(e.target.files)}
className="sr-only"
aria-hidden="true"
/>
{/* Client-side validation error */}
{clientError && (
<p role="alert" className="text-sm text-destructive">
{clientError}
</p>
)}
{/* File count */}
{multiple && files.length > 0 && (
<p className="text-sm text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"}
</p>
)}
{/* Previews */}
{previews.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{previews.map((preview, index) => (
<div key={preview.url} className="relative group rounded-lg overflow-hidden border">
<img
src={preview.url}
alt={preview.file.name}
className="w-full h-24 object-cover"
/>
<div className="p-2 text-xs">
<p className="truncate font-medium">{preview.file.name}</p>
<p className="text-muted-foreground">
{formatFileSize(preview.file.size)}
</p>
</div>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
aria-label={`Remove ${preview.file.name}`}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
);
}// components/profile-form/uploads-step.tsx
"use client";
import { FileUpload } from "./file-upload";
export function UploadsStep() {
return (
<fieldset>
<legend className="text-lg font-semibold">Upload Files</legend>
<div className="space-y-6 mt-4">
<FileUpload
name="profilePhoto"
label="Profile Photo"
/>
<FileUpload
name="architectureDiagrams"
label="Architecture Diagrams"
multiple
maxFiles={10}
/>
<FileUpload
name="siteScreenshots"
label="Site Screenshots"
multiple
maxFiles={10}
/>
</div>
</fieldset>
);
}How it maps:
Then a thumbnail preview should appear→URL.createObjectURL()inaddFiles()And the file name "headshot.jpg" should be displayed→preview.file.namein the preview gridThen the drop zone border should turn blue→isDragging && "border-primary bg-primary/5"And the text should change to "Drop file here"→isDragging ? "Drop file here" : ...When they click the remove button→removeFile(index)revokes URL and updates fieldThen the file input should be disabled→isMaxReacheddisables input and drop zone
Gherkin to shadcn Accessibility
Gherkin accessibility scenarios define ARIA attributes, keyboard behavior, and screen reader announcements that shadcn/ui components must satisfy.
Gherkin
Feature: Accessible Form with shadcn/ui
Scenario: Every required field has accessible markup
Given the architect opens the form
Then every required input should have aria-required="true"
And every input should have an associated label via htmlFor/id
And required fields should show a visual asterisk
Scenario: Validation errors are announced to screen readers
Given the architect submits step 1 with empty required fields
Then each error message should have role="alert"
And the first error message should receive focus
And invalid inputs should have aria-invalid="true"
Scenario: Error messages are linked to inputs
Given the "Email" field has a validation error
Then the input should have aria-describedby pointing to the error message
And a screen reader should read "Email, invalid, Please enter a valid email address"
Scenario: Step changes are announced
Given the architect moves from step 1 to step 2
Then the screen reader should announce "Step 2 of 6: Experience"
And focus should move to the first field of step 2
Scenario: Keyboard navigation works through the form
Given the architect is on step 1
When they press Tab repeatedly
Then focus should move through: Full Name, Email, Phone, LinkedIn URL, Next button
And pressing Enter on the Next button should advance the form
Scenario: File upload is keyboard accessible
Given the architect tabs to the upload drop zone
Then the drop zone should show a focus ring
When they press Enter
Then the file picker dialog should openCode
// components/profile-form/accessible-field.tsx
"use client";
import { useFormContext } from "react-hook-form";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import type { ArchitectProfile } from "@/lib/schemas/architect-profile";
import type { HTMLInputTypeAttribute } from "react";
interface AccessibleFieldProps {
name: keyof ArchitectProfile;
label: string;
type?: HTMLInputTypeAttribute;
required?: boolean;
placeholder?: string;
}
export function AccessibleField({
name,
label,
type = "text",
required = false,
placeholder,
}: AccessibleFieldProps) {
const {
register,
formState: { errors },
} = useFormContext<ArchitectProfile>();
const error = errors[name];
const errorId = `${name}-error`;
const descriptionId = error ? errorId : undefined;
return (
<div className="space-y-1.5">
<Label htmlFor={name}>
{label}
{required && (
<span aria-hidden="true" className="text-destructive ml-0.5">
*
</span>
)}
</Label>
<Input
id={name}
type={type}
placeholder={placeholder}
aria-required={required}
aria-invalid={!!error}
aria-describedby={descriptionId}
{...register(name)}
/>
{error && (
<p
id={errorId}
role="alert"
className="text-sm text-destructive"
>
{error.message as string}
</p>
)}
</div>
);
}// components/profile-form/step-announcer.tsx
"use client";
import { useEffect, useRef } from "react";
import { STEPS, useProfileFormStore } from "@/stores/profile-form-store";
export function StepAnnouncer() {
const { currentStep } = useProfileFormStore();
const announcerRef = useRef<HTMLDivElement>(null);
const previousStep = useRef(currentStep);
useEffect(() => {
if (currentStep !== previousStep.current) {
previousStep.current = currentStep;
const stepLabel = STEPS[currentStep - 1].label;
if (announcerRef.current) {
announcerRef.current.textContent =
`Step ${currentStep} of ${STEPS.length}: ${stepLabel}`;
}
}
}, [currentStep]);
return (
<div
ref={announcerRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
);
}// components/profile-form/auto-focus-step.tsx
"use client";
import { useEffect, useRef } from "react";
import { useProfileFormStore } from "@/stores/profile-form-store";
export function AutoFocusStep({ children }: { children: React.ReactNode }) {
const { currentStep } = useProfileFormStore();
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Focus the first focusable element in the new step
const firstInput = containerRef.current?.querySelector<HTMLElement>(
"input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea:not([disabled])"
);
firstInput?.focus();
}, [currentStep]);
return <div ref={containerRef}>{children}</div>;
}How it maps:
Then every required input should have aria-required="true"→aria-required={required}propAnd invalid inputs should have aria-invalid="true"→aria-invalid={!!error}Then the input should have aria-describedby pointing to the error message→aria-describedby={errorId}Then the screen reader should announce "Step 2 of 6: Experience"→StepAnnouncerwitharia-live="polite"And focus should move to the first field of step 2→AutoFocusStepqueries first inputThen the drop zone should show a focus ring→tabIndex={0}+ Tailwindfocus-visible:ring-2on drop zone
Gherkin to Dual Validation (Client + Server)
Gherkin security scenarios define why validation must run on both client and server.
Gherkin
Feature: Dual Validation
Scenario: Client-side validation gives instant feedback
Given the architect types "bad-email" in the "Email" field
And they blur the field
Then they should see "Please enter a valid email address" within 100ms
And no network request should be made
Scenario: Server-side validation catches bypassed client checks
Given an attacker sends a direct POST request
And the payload contains email "not-an-email"
Then the server should reject the request
And return error "Please enter a valid email address"
Scenario: File type is validated on both client and server
Given the architect uploads a file named "exploit.php.jpg"
And the file's actual MIME type is "application/x-php"
Then the client should reject based on MIME type check
And if somehow it reaches the server, the server should also reject it
Scenario: Both validations use the same zod schema
Given the schema defines email as z.string().email()
Then the client-side resolver should use that schema
And the server action should use that same schema
And error messages should be identical on both sidesCode
// lib/schemas/architect-profile.ts (shared -- used by BOTH client and server)
// This is the SAME file shown in "Gherkin to Zod Schema" above.
// The key point: ONE schema, TWO consumers.
import { z } from "zod";
export const personalInfoSchema = z.object({
fullName: z.string().min(1, "Full name is required").min(2, "Name must be at least 2 characters"),
email: z.string().min(1, "Email is required").email("Please enter a valid email address"),
// ... rest of schema
});// Client uses it via zodResolver:
// components/profile-form/profile-form-provider.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { architectProfileSchema } from "@/lib/schemas/architect-profile";
const methods = useForm<ArchitectProfile>({
resolver: zodResolver(architectProfileSchema), // ← same schema
mode: "onBlur",
});// Server uses it via safeParse:
// lib/actions/create-profile.ts
"use server";
import { architectProfileSchema } from "@/lib/schemas/architect-profile"; // ← same schema
export async function createProfile(prevState: ProfileActionState, formData: FormData) {
const result = architectProfileSchema.safeParse(rawData); // ← same schema
if (!result.success) {
// Return field errors -- messages are identical to client-side
}
}How it maps:
Then they should see "..." within 100ms→ client-sidezodResolverruns in-browser, no networkThen the server should reject the request→safeParsein the server actionThen error messages should be identical on both sides→ same schema file imported by both- The architecture:
lib/schemas/is a shared layer. Client components import it forzodResolver. Server actions import it forsafeParse. One source of truth.
Gherkin to Full Form Assembly
Gherkin integration scenarios define how all pieces compose into the complete form.
Gherkin
Feature: Complete Profile Form Assembly
Scenario: Form renders all 6 steps with correct flow
Given the architect navigates to "/profile/create"
Then the step indicator should show 6 steps
And step 1 "Personal Info" should render with name, email, phone, LinkedIn fields
When they complete step 1 and click "Next"
Then step 2 "Experience" should render with years, role, certifications, bio fields
When they complete step 2 and click "Next"
Then step 3 "Job History" should render with repeatable job entry groups
When they complete step 3 and click "Next"
Then step 4 "Skills" should render with platform checkboxes and proficiency radios
When they complete step 4 and click "Next"
Then step 5 "Uploads" should render with photo, diagrams, and screenshots upload zones
When they complete step 5 and click "Next"
Then step 6 "Review" should show a summary of all data with edit links
And the submit button should be enabledCode
// app/profile/create/page.tsx
import { ProfileFormProvider } from "@/components/profile-form/profile-form-provider";
import { ProfileFormWizard } from "@/components/profile-form/profile-form-wizard";
export default function CreateProfilePage() {
return (
<main className="max-w-2xl mx-auto py-10 px-4">
<h1 className="text-2xl font-bold mb-6">Create Your Profile</h1>
<ProfileFormProvider>
<ProfileFormWizard />
</ProfileFormProvider>
</main>
);
}// components/profile-form/profile-form-wizard.tsx
"use client";
import { useProfileFormStore } from "@/stores/profile-form-store";
import { StepIndicator } from "./step-indicator";
import { StepNavigation } from "./step-navigation";
import { StepAnnouncer } from "./step-announcer";
import { AutoFocusStep } from "./auto-focus-step";
import { PersonalInfoStep } from "./personal-info-step";
import { ExperienceStep } from "./experience-step";
import { JobHistoryStep } from "./job-history-step";
import { SkillsStep } from "./skills-step";
import { UploadsStep } from "./uploads-step";
import { SubmitStep } from "./submit-step";
const STEP_COMPONENTS: Record<number, React.ComponentType> = {
1: PersonalInfoStep,
2: ExperienceStep,
3: JobHistoryStep,
4: SkillsStep,
5: UploadsStep,
6: SubmitStep,
};
export function ProfileFormWizard() {
const { currentStep } = useProfileFormStore();
const StepComponent = STEP_COMPONENTS[currentStep];
return (
<div className="space-y-8">
<StepIndicator />
<StepAnnouncer />
<AutoFocusStep>
<StepComponent />
</AutoFocusStep>
<StepNavigation />
</div>
);
}Complete File Structure
app/
profile/
create/
page.tsx ← Page component
components/
profile-form/
profile-form-provider.tsx ← useForm + FormProvider + zodResolver
profile-form-wizard.tsx ← Step router
step-indicator.tsx ← Progress bar with checkmarks
step-navigation.tsx ← Back/Next/Submit buttons
step-announcer.tsx ← Screen reader step announcements
auto-focus-step.tsx ← Auto-focus first field on step change
accessible-field.tsx ← Reusable accessible input
personal-info-step.tsx ← Step 1
experience-step.tsx ← Step 2
job-history-step.tsx ← Step 3 (useFieldArray)
skills-step.tsx ← Step 4
uploads-step.tsx ← Step 5
file-upload.tsx ← Drag/drop file upload with preview
submit-step.tsx ← Step 6: Review + useActionState submit
lib/
schemas/
architect-profile.ts ← Shared zod schemas (client + server)
actions/
create-profile.ts ← Server Action
stores/
profile-form-store.ts ← Zustand step state
Recipe
Quick-reference recipe card -- copy-paste ready.
The Gherkin-to-Code translation pattern:
| Gherkin Keyword | Code Destination |
|---|---|
Given ... field is empty | zod .min(1, "...") |
Then they should see "error" | zod error message string |
When they type "..." | register("fieldName") on input |
When they click "Next" | trigger(stepFields) then goToNext() |
When they click "Add Another" | useFieldArray.append() |
Then a spinner should appear | isPending from useActionState |
Then the server should reject | safeParse() in server action |
Then role="alert" | <p role="alert"> on error messages |
Then screen reader announces | aria-live="polite" region |
Then thumbnail preview | URL.createObjectURL(file) |
When they drag a file | onDragOver/onDrop handlers |
When to reach for this: After completing the Gherkin Decision Checklist. Use the checklist to define WHAT the form does, then this guide to implement HOW.
Gotchas
-
Sharing schemas between client and server -- the schema file must NOT import server-only modules. Keep it in
lib/schemas/with only zod imports. -
useActionState requires a two-argument server action -- the first arg is previous state, not form data.
(prevState, formData) => ...not(formData) => .... -
FormData and react-hook-form -- react-hook-form manages state internally. To submit via server action, you must manually build
FormDatafromgetValues(). The form's nativeactionprop bypasses react-hook-form. -
Zustand store resets on page navigation -- if the user navigates away and back, the step store resets. Consider
persistmiddleware if you need cross-navigation persistence. -
File previews leak memory -- every
URL.createObjectURL()must have a matchingURL.revokeObjectURL()on removal or unmount. TheFileUploadcomponent handles this inremoveFile(). -
Server actions and redirect() --
redirect()throws a special error internally. Your catch block must re-throw it or the redirect will be swallowed.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Formik instead of react-hook-form | Legacy project already on Formik | Starting fresh (RHF has better performance) |
| Redux for step state | App already uses Redux heavily | Simpler state needs (zustand is lighter) |
| tRPC instead of server actions | Need type-safe API layer beyond forms | Simple form submission to own backend |
| Conform for server-first forms | Progressive enhancement is top priority | Complex client-side interactions needed |
Related
- Gherkin Form Decision Checklist -- the 10-question checklist that precedes this implementation
- Testing Forms -- unit testing form components
- Forms & Validation section -- react-hook-form and zod patterns
- Playwright Patterns -- E2E testing for the form