Complete file-by-file breakdown of the Cloud Architect Profile Form. Every file needed for a working multi-step form inside Next.js App Router with React 19 server actions, BDD-aligned validation, upload protection, pending UX, and Playwright-ready test hooks.
The Gherkin files are the source of truth. Every validation rule, UI behavior, and error message originates here. Developers implement code to satisfy these scenarios.
# features/profile-form/04-validation.featureFeature: Profile Form Validation Background: Given the architect is logged in And they navigate to "/profile/create" Scenario: Required fields show errors when empty Given the architect is on step 1 "Personal Info" And they have not filled in any fields When they click "Next" Then they should see "Full name is required" And they should see "Email is required" And the form should not advance to step 2 Scenario: Email format is validated Given the architect is on step 1 "Personal Info" When they type "not-an-email" in the "Email" field And they click "Next" Then they should see "Please enter a valid email address" Scenario: LinkedIn URL must point to linkedin.com Given the architect is on step 1 "Personal Info" When they type "https://twitter.com/someone" in the "LinkedIn URL" field And 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 is on step 2 "Experience" When they type "-3" in the "Years of Experience" field And 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 is on step 4 "Skills" And no cloud platforms are checked 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 is on step 3 "Job History" When they set start date to "2025-06-01" And they set end date to "2024-01-01" And they click "Next" Then they should see "End date must be after start date" Scenario: Bio cannot exceed 1000 characters Given the architect is on step 2 "Experience" When they type 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 is on step 5 "Uploads" When they select 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 is on step 5 "Uploads" When they select a .exe file for "Profile Photo" Then they should see "Only JPEG, PNG, and WebP files are accepted"
Key code points:
Background runs before every scenario - sets up a logged-in user on the create page
Each Scenario maps to exactly one test case - the title describes the expected behavior
Given / When / Then steps read like plain English so non-developers can review acceptance criteria
Error message strings (e.g., "Full name is required") must match the zod schema messages exactly
The .refine() scenario (end date after start date) shows cross-field validation in Gherkin form
File upload scenarios test both size limits and MIME type rejection as separate cases
The single source of truth for all validation. Every zod error message matches a Gherkin Then they should see "..." exactly. Imported by the client (zodResolver) and the server action (safeParse).
// lib/schemas/architect-profile.tsimport { z } from "zod";// ── Shared constants ───────────────────────────────────const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MBconst MAX_DIAGRAM_COUNT = 10;// ── Reusable file validator ────────────────────────────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" );// ── 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()), 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 ────────────────────────────────────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(),});// ── Per-step schemas (used by StepNavigation.trigger()) ──export const STEP_SCHEMAS = { 1: personalInfoSchema, 2: experienceSchema, 3: jobHistorySchema, 4: skillsSchema, 5: uploadsSchema,} as const;// ── Combined schema (used by zodResolver + server action) ──export const architectProfileSchema = personalInfoSchema .merge(experienceSchema) .merge(jobHistorySchema) .merge(skillsSchema) .merge(uploadsSchema);// ── Inferred types ─────────────────────────────────────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>;
Key code points:
imageFileSchema is a reusable zod refinement - validates both size (5MB) and MIME type, shared by all upload fields
Each step has its own exported schema (personalInfoSchema, experienceSchema, etc.) so trigger() can validate one step at a time
.refine() on jobEntrySchema handles cross-field validation (end date > start date) with a custom path targeting the specific field
.or(z.literal("")) on linkedinUrl allows the field to be left empty while still validating format when filled
STEP_SCHEMAS maps step numbers to their schema - used by StepNavigation to validate only the current step's fields
architectProfileSchema merges all step schemas into one - used by zodResolver (client) and safeParse (server) for full-form validation
z.infer<typeof ...> generates TypeScript types from each schema - single source of truth for both validation and types
Zustand store owns the step state machine. Knows which step is active, which are completed, and whether navigation is allowed. Decoupled from form data (react-hook-form owns that).
Server action receives FormData, validates server-side with the same zod schema, validates uploaded files independently, persists data, and redirects. The two-argument signature (prevState, formData) is required by useActionState.
// lib/actions/create-profile.ts"use server";import { z } from "zod";import { redirect } from "next/navigation";import { architectProfileSchema } from "@/lib/schemas/architect-profile";// Server-only file schema -- stricter, checks actual MIMEconst 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 (same schema as client) ── const textSchema = architectProfileSchema.omit({ profilePhoto: true, architectureDiagrams: true, siteScreenshots: true, }); const textResult = textSchema.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, }, }; } } } const screenshots = formData.getAll("siteScreenshots") as File[]; for (const screenshot of screenshots) { if (screenshot.size > 0) { const fileResult = serverFileSchema.safeParse(screenshot); if (!fileResult.success) { return { success: false, message: "", fieldErrors: { siteScreenshots: 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")) ); const screenshotUrls = await Promise.all( screenshots .filter((f) => f.size > 0) .map((f) => uploadToStorage(f, "screenshots")) ); // ── 6. Create profile record ───────────────────── const profile = await createProfileRecord({ ...textResult.data, photoUrl, diagramUrls, screenshotUrls, }); redirect(`/profile/${profile.id}`); } catch (error) { // redirect() throws internally -- 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: {}, }; }}// Replace with your actual DB/storage layerasync function findProfileByEmail(email: string) { return null;}async function uploadToStorage(file: File, folder: string): Promise<string> { return `https://storage.example.com/${folder}/${file.name}`;}async function createProfileRecord(data: Record<string, unknown>) { return { id: "new-profile-id" };}
Key code points:
"use server" marks this as a server action - it runs on the server, never shipped to the client bundle
serverFileSchema duplicates file validation server-side - never trust client-only checks (users can bypass the browser)
(prevState, formData) two-argument signature is required by useActionState - prevState carries the previous return value
ProfileActionState return type has fieldErrors: Record<string, string> - the submit step maps these back to react-hook-form and navigates to the correct step
.omit({ profilePhoto: true, ... }) strips file fields from the text schema - files are validated separately since safeParse can't handle File objects from FormData the same way
formData.getAll("certifications") retrieves multiple values for the same form field name - used for array fields (certifications, platforms, specialties)
redirect() throws internally in Next.js - the catch block must re-throw NEXT_REDIRECT or the redirect silently fails
Steps 1-6 are numbered comments - the action follows a strict pipeline: parse → validate text → validate files → check duplicates → upload → create record → redirect
Creates the single useForm instance shared across all steps. The zodResolver connects zod validation to react-hook-form. mode: "onBlur" gives real-time feedback when the user tabs away from a field.
Routes to the correct step component based on zustand state. Wraps the active step in accessibility helpers (screen reader announcer and auto-focus).
// 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" data-testid="profile-form-wizard"> <StepIndicator /> <StepAnnouncer /> <AutoFocusStep> <StepComponent /> </AutoFocusStep> <StepNavigation /> </div> );}
Key code points:
STEP_COMPONENTS is a Record<number, React.ComponentType> lookup - maps step number to the component to render
useProfileFormStore() reads currentStep from zustand - the wizard re-renders when the step changes
StepIndicator + StepAnnouncer + AutoFocusStep + StepNavigation wrap the active step - separation of concerns between progress UI, accessibility, and navigation
data-testid="profile-form-wizard" provides a Playwright hook for the wizard container
The progress bar. Shows checkmarks for completed steps, highlights the current step, and disables future steps. Each step button has ARIA labels for screen readers.
Back/Next/Submit buttons. "Next" validates the current step's fields via trigger() before advancing. The step-to-field mapping ensures only the active step's fields are checked.
togglePlatform / toggleSpecialty manually manage array state via setValue with { shouldValidate: true } - checkboxes don't use register() because they map to arrays, not individual values
watch("cloudPlatforms") dynamically renders proficiency radio groups - only shows AWS/Azure/GCP proficiency when the platform is selected
`${platform}Proficiency` as keyof ArchitectProfile computes the field name dynamically - e.g., selecting "aws" renders the awsProficiency radio group
RadioGroup with onValueChange syncs the selected level back to the form via setValue
PLATFORMS uses as const - TypeScript narrows the value to the literal union "aws" | "azure" | "gcp" matching the zod enum
Drag-and-drop with preview thumbnails, client-side type/size validation, and a remove button per file. Uses useController to sync files into react-hook-form state.
Bridges react-hook-form values to the server action via FormData. Uses useActionState for pending/error state. Maps server field errors back to the correct step.
useActionState(createProfile, initialState) returns [state, formAction, isPending] - bridges the server action to React's pending UI
FIELD_TO_STEP maps field names to step numbers - when the server returns a field error, the UI navigates the user back to the correct step
The first useEffect iterates state.fieldErrors and calls setError() for each - maps server validation errors back into react-hook-form so they display inline
goToStep(targetStep) auto-navigates to the step containing the first error - the user doesn't have to manually find which step failed
handleSubmit manually builds FormData from getValues() - bridges react-hook-form's state to the server action's expected FormData input
formData.append() (not set) is used for array fields - certifications, platforms, specialties, and multiple file uploads
JSON.stringify(values.jobs) serializes the job array - complex nested objects can't be sent as individual FormData entries
isPending disables the submit button and shows a Loader2 spinner - prevents double submission
ReviewCard component renders a summary card per step with an "Edit" button - lets the user jump back to any step before submitting
Missing data-testid -- Playwright tests rely on data-testid attributes. Every interactive element needs one. Add them when you create a component, not later.
Schema file importing server modules -- lib/schemas/architect-profile.ts is shared between client and server. It must not import anything from "use server" files, next/headers, or database clients.
useFieldArray index drift -- when you remove a job entry, all subsequent indexes shift. Use field.id (from useFieldArray) as the React key, not the array index.
File previews not cleaned up -- every URL.createObjectURL must have a revokeObjectURL when the file is removed. The FileUpload component handles this, but if you build custom upload UI, track your URLs.
redirect() in server actions -- Next.js redirect() throws an internal error. Your try/catch in the server action must re-throw it, or the redirect silently fails.
Playwright file upload -- use page.locator('input[type=file]').setInputFiles() with a real file path or a Buffer. The visual drop zone is not a real file input, so target the hidden <input> inside it.