React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

gherkinbddreact-hook-formzodserver-actionsuseActionState

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 input
  • Then the form state should contain those values → react-hook-form tracks values automatically via uncontrolled inputs
  • When they click "Add Another Position"useFieldArray.append()
  • When they click "Remove"useFieldArray.remove(index)
  • And they move focus to the next fieldmode: "onBlur" in useForm

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" link

Code

// 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 1currentStep state in zustand
  • And steps 2 through 6 should be lockedcanNavigateTo() returns false
  • Then step 1 should show a checkmarkgetStepStatus(1) === "completed" renders <Check />
  • When they click step 2 in the progress indicatorgoToStep(2)
  • When they click "Next" → validates current step fields with trigger(), then goToNext()

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 persisted

Code

// 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 filesFormData parsing in the action
  • Then the server should return field error "..."fieldErrors in the return type
  • And the error should be associated with the "email" field{ email: "..." } in fieldErrors
  • Then the server should reject the file → dual validation with serverFileSchema
  • Then 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 page

Code

// 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..."isPending from useActionState
  • And a spinner should appear<Loader2 className="animate-spin" />
  • Then the form should navigate to step 1FIELD_TO_STEP lookup + goToStep()
  • And the screen reader should announce the errorrole="alert" and aria-live="assertive"
  • Then a toast notification should appearuseToast() 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 appearURL.createObjectURL() in addFiles()
  • And the file name "headshot.jpg" should be displayedpreview.file.name in the preview grid
  • Then the drop zone border should turn blueisDragging && "border-primary bg-primary/5"
  • And the text should change to "Drop file here"isDragging ? "Drop file here" : ...
  • When they click the remove buttonremoveFile(index) revokes URL and updates field
  • Then the file input should be disabledisMaxReached disables 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 open

Code

// 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} prop
  • And invalid inputs should have aria-invalid="true"aria-invalid={!!error}
  • Then the input should have aria-describedby pointing to the error messagearia-describedby={errorId}
  • Then the screen reader should announce "Step 2 of 6: Experience"StepAnnouncer with aria-live="polite"
  • And focus should move to the first field of step 2AutoFocusStep queries first input
  • Then the drop zone should show a focus ringtabIndex={0} + Tailwind focus-visible:ring-2 on 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 sides

Code

// 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-side zodResolver runs in-browser, no network
  • Then the server should reject the requestsafeParse in the server action
  • Then 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 for zodResolver. Server actions import it for safeParse. 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 enabled

Code

// 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 KeywordCode Destination
Given ... field is emptyzod .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 appearisPending from useActionState
Then the server should rejectsafeParse() in server action
Then role="alert"<p role="alert"> on error messages
Then screen reader announcesaria-live="polite" region
Then thumbnail previewURL.createObjectURL(file)
When they drag a fileonDragOver/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 FormData from getValues(). The form's native action prop bypasses react-hook-form.

  • Zustand store resets on page navigation -- if the user navigates away and back, the step store resets. Consider persist middleware if you need cross-navigation persistence.

  • File previews leak memory -- every URL.createObjectURL() must have a matching URL.revokeObjectURL() on removal or unmount. The FileUpload component handles this in removeFile().

  • Server actions and redirect() -- redirect() throws a special error internally. Your catch block must re-throw it or the redirect will be swallowed.

Alternatives

AlternativeUse WhenDon't Use When
Formik instead of react-hook-formLegacy project already on FormikStarting fresh (RHF has better performance)
Redux for step stateApp already uses Redux heavilySimpler state needs (zustand is lighter)
tRPC instead of server actionsNeed type-safe API layer beyond formsSimple form submission to own backend
Conform for server-first formsProgressive enhancement is top priorityComplex client-side interactions needed