DEV Community

Cover image for Carific.ai: Building a Type-Safe Profile Editor with TanStack Form, Zod, and Live PDF Preview
Abdullah Jan
Abdullah Jan

Posted on

Carific.ai: Building a Type-Safe Profile Editor with TanStack Form, Zod, and Live PDF Preview

This is my fourth dev.to post. Previous posts: Auth System, AI Resume Analyzer, and Structured AI Output.

"TypeError: Cannot read properties of undefined (reading 'company')"

I stared at the error. The form worked fine with basic fields. But the moment I added dynamic arrays for work experience, education, and projects, TypeScript lost its mind.

The problem? I had types defined in three places: Prisma schema, Zod validation, and a manual types.ts file. They were already drifting apart.

This is the story of how I built a profile editor with 10+ dynamic sections, live PDF preview, and zero type duplication — using Zod as the single source of truth.


The Stack

Package Version Purpose
Next.js 16.0.10 App framework
React 19.2.3 UI library
TanStack Form 1.27.1 Type-safe form state
Zod 4.1.13 Schema validation & type source
@react-pdf/renderer 4.3.1 Live PDF generation
date-fns 4.1.0 Date formatting
react-day-picker 9.11.3 Calendar component

Chapter 1: The Type Duplication Problem

The Mess I Started With

When I first built the profile editor, I had types everywhere:

// ❌ types.ts - Manual type definitions
export interface WorkExperience {
  id?: string;
  company: string;
  position: string;
  // ... 8 more fields
}

// ❌ profile-update.ts - Zod schema (different structure!)
export const WorkExperienceSchema = z.object({
  id: z.string().optional(),
  company: z.string().min(1),
  position: z.string().min(1),
  // ... slightly different validation
});

// ❌ Prisma schema - Yet another definition
model WorkExperience {
  id        String @id
  company   String
  position  String
  // ... database-specific fields
}
Enter fullscreen mode Exit fullscreen mode

Three sources of "truth." When I added location to the Prisma schema, I forgot to add it to types.ts. The form silently dropped the field. Two hours of debugging later, I found it.

The Fix: Zod as Single Source of Truth

The solution was obvious in hindsight: derive all types from Zod schemas.

// lib/validations/profile-update.ts

// Define the schema ONCE
export const WorkExperienceSchema = z.object({
  id: z.string().optional(),
  company: z.string().min(1, "Company name is required"),
  position: z.string().min(1, "Position is required"),
  location: z.string().optional(),
  startDate: z.string().optional(),
  endDate: z.string().optional(),
  current: z.boolean().default(false),
  bullets: z.array(z.string()).default([]),
});

// Derive the type FROM the schema
export type WorkExperienceInput = z.infer<typeof WorkExperienceSchema>;
Enter fullscreen mode Exit fullscreen mode

Now there's exactly one place to update. Add a field to the schema, and TypeScript immediately tells you everywhere that needs updating.

I deleted the redundant types.ts file entirely. One less thing to maintain.

The Form Values Type

For the form, I needed a slightly different type — one where all optional fields have default values (empty strings, not undefined):

// Form values type - matches actual form state
export type ProfileFormValues = {
  displayName: string;
  headline: string;
  email: string;
  // ... all strings, no undefined
  workExperiences: Required<WorkExperienceInput>[];
  educations: Required<EducationInput>[];
  // ... all arrays with Required<T>
};
Enter fullscreen mode Exit fullscreen mode

This ensures the form always has values to work with. No more "cannot read property of undefined."


Chapter 2: Dynamic Array Fields That Don't Suck

The Problem with Array Forms

TanStack Form handles arrays beautifully with mode="array". But I kept writing the same boilerplate:

// ❌ Repetitive pattern in every section
<form.Field name="workExperiences" mode="array">
  {(field) => (
    <>
      <Button onClick={() => field.pushValue(createEmpty())}>Add</Button>
      {field.state.value.map((item, index) => (
        <div key={item.id}>
          <form.Field name={`workExperiences[${index}].company`}>
            {(subField) => <Input value={subField.state.value} ... />}
          </form.Field>
          {/* 10 more fields... */}
        </div>
      ))}
    </>
  )}
</form.Field>
Enter fullscreen mode Exit fullscreen mode

The pattern was fine, but I had 10 sections: work experience, education, projects, skills, certifications, languages, achievements, social links, volunteer experience, and hobbies.

The Solution: withForm HOC + Card Headers with Item Numbers

TanStack Form's withForm higher-order component lets you create pre-typed section components. I also added card headers with item numbers for better UX — users can easily identify which experience they're editing:

// components/profile-editor/sections/work-experience-section.tsx

import { withForm } from "@/hooks/form";
import {
  type ProfileFormValues,
  createEmptyWorkExperience,
} from "@/lib/validations/profile-update";

export const WorkExperienceSection = withForm({
  defaultValues: {} as ProfileFormValues,
  render: function Render({ form }) {
    return (
      <div className="space-y-4">
        <div className="flex items-center justify-between">
          <h3 className="text-md font-medium">Work Experience</h3>
          <form.Field name="workExperiences" mode="array">
            {(field) => (
              <Button
                type="button"
                variant="outline"
                size="sm"
                onClick={() => field.pushValue(createEmptyWorkExperience())}
              >
                <Plus className="h-4 w-4 mr-1" />
                Add Experience
              </Button>
            )}
          </form.Field>
        </div>

        <form.Field name="workExperiences" mode="array">
          {(field) => (
            <div className="space-y-4">
              {field.state.value.map((workExperience, index) => (
                <Card key={workExperience.id}>
                  <CardHeader>
                    <div className="flex items-center justify-between">
                      <CardTitle className="text-sm font-medium">
                        Experience {index + 1}
                      </CardTitle>
                      <Button
                        type="button"
                        variant="ghost"
                        size="sm"
                        onClick={() => field.removeValue(index)}
                      >
                        <Trash2 className="h-4 w-4 text-destructive" />
                      </Button>
                    </div>
                  </CardHeader>
                  <CardContent>
                    {/* Fields here */}
                  </CardContent>
                </Card>
              ))}
            </div>
          )}
        </form.Field>
      </div>
    );
  },
});
Enter fullscreen mode Exit fullscreen mode

The key insight: defaultValues: {} as ProfileFormValues gives TypeScript the type information it needs. Now form.AppField name="workExperiences[${index}].company" is fully typed.

Factory Functions for New Items

Each array type needs a factory function that returns a complete object with all required fields:

// lib/validations/profile-update.ts

export const createEmptyWorkExperience = (): Required<WorkExperienceInput> => ({
  id: crypto.randomUUID(),
  company: "",
  position: "",
  location: "",
  startDate: "",
  endDate: "",
  current: false,
  bullets: [],
});
Enter fullscreen mode Exit fullscreen mode

Using crypto.randomUUID() for IDs ensures React's key prop is stable and unique, even for items that haven't been saved to the database yet.


Chapter 3: Custom Field Components

The StringArrayField Component

Work experience bullets, education highlights, project features — they're all arrays of strings. Instead of building custom UI for each, I created a reusable StringArrayField:

// components/form/form-fields.tsx

export function StringArrayField({
  label,
  description,
  placeholder = "Enter item...",
  addButtonText = "Add Item",
}: StringArrayFieldProps) {
  const field = useFieldContext<string[]>();
  const items = field.state.value ?? [];

  const addItem = () => {
    field.handleChange([...items, ""]);
  };

  const removeItem = (index: number) => {
    field.handleChange(items.filter((_, i) => i !== index));
  };

  const updateItem = (index: number, value: string) => {
    const newItems = [...items];
    newItems[index] = value;
    field.handleChange(newItems);
  };

  return (
    <Field>
      <FieldContent>
        <div className="flex items-center justify-between">
          <FieldLabel>{label}</FieldLabel>
          <Button type="button" variant="ghost" size="sm" onClick={addItem}>
            <Plus className="h-3 w-3 mr-1" />
            {addButtonText}
          </Button>
        </div>
      </FieldContent>
      <div className="space-y-2">
        {items.map((item, index) => (
          <div key={index} className="flex items-center gap-2">
            <span className="text-muted-foreground text-xs w-5">
              {index + 1}.
            </span>
            <Input
              value={item}
              onChange={(e) => updateItem(index, e.target.value)}
              onBlur={field.handleBlur}
              placeholder={placeholder}
              className="flex-1"
            />
            <Button
              type="button"
              variant="ghost"
              size="sm"
              onClick={() => removeItem(index)}
            >
              <Trash2 className="h-4 w-4 text-destructive" />
            </Button>
          </div>
        ))}
        {items.length === 0 && (
          <p className="text-sm text-muted-foreground italic">
            No items yet. Click "{addButtonText}" to add one.
          </p>
        )}
      </div>
    </Field>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now adding bullet points to work experience is one line:

<form.AppField name={`workExperiences[${index}].bullets`}>
  {(subField) => (
    <subField.StringArrayField
      label="Key Responsibilities & Achievements"
      placeholder="Describe a responsibility or achievement..."
      addButtonText="Add Bullet"
    />
  )}
</form.AppField>
Enter fullscreen mode Exit fullscreen mode

The DateField with shadcn Calendar

Native date inputs are ugly and inconsistent across browsers. I replaced them with shadcn's Calendar component:

// components/form/form-fields.tsx

export function DateField({
  label,
  description,
  placeholder = "Pick a date",
}: DateFieldProps) {
  const field = useFieldContext<string>();

  // Convert string (yyyy-MM-dd) to Date for Calendar
  const dateValue = field.state.value
    ? parse(field.state.value, "yyyy-MM-dd", new Date())
    : undefined;
  const isValidDate = dateValue && !isNaN(dateValue.getTime());

  const handleSelect = (date: Date | undefined) => {
    if (date) {
      field.handleChange(format(date, "yyyy-MM-dd"));
    } else {
      field.handleChange("");
    }
  };

  return (
    <FormBase label={label} description={description}>
      <Popover>
        <PopoverTrigger asChild>
          <Button
            variant="outline"
            className={cn(
              "w-full justify-start text-left font-normal",
              !field.state.value && "text-muted-foreground"
            )}
          >
            <CalendarIcon className="mr-2 h-4 w-4" />
            {isValidDate ? format(dateValue, "PPP") : placeholder}
          </Button>
        </PopoverTrigger>
        <PopoverContent className="w-auto p-0" align="start">
          <Calendar
            mode="single"
            selected={dateValue}
            onSelect={handleSelect}
            autoFocus
          />
        </PopoverContent>
      </Popover>
    </FormBase>
  );
}
Enter fullscreen mode Exit fullscreen mode

The isValidDate check is crucial. Without it, malformed date strings (from bad data or migrations) would crash the component with "Invalid Date."


Chapter 4: Consolidating PDF Preview Components

The Duplication Problem

I had two components doing similar things:

  1. ResumeViewer — A standalone PDF viewer with download
  2. ProfileEditor — Had its own PDF preview logic embedded

Both had duplicate code for the download button, loading states, and PDF rendering. When I fixed a bug in one, I'd forget to fix it in the other.

The Solution: Shared PDFPreview Component

I extracted the common logic into a reusable PDFPreview component:

// components/pdf/pdf-preview.tsx

const PDFViewerClient = dynamic(
  () => import("./pdf-viewer-client").then((mod) => mod.PDFViewerClient),
  {
    ssr: false,
    loading: () => (
      <div
        className="flex items-center justify-center bg-muted"
        style={{ height: "calc(100vh - 180px)" }}
      >
        <Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
        <p className="text-muted-foreground">Loading PDF viewer...</p>
      </div>
    ),
  }
);

export function PDFPreview({
  profile,
  title = "Resume Preview",
  showDownload = true,
  height = "calc(100vh - 180px)",
}: PDFPreviewProps) {
  const [isDownloading, setIsDownloading] = useState(false);

  const handleDownload = async () => {
    setIsDownloading(true);
    try {
      const blob = await pdf(<ResumeTemplate profile={profile} />).toBlob();
      const url = URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = `${profile.displayName?.replace(/\s+/g, "_") || "resume"}_resume.pdf`;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      URL.revokeObjectURL(url);
      toast.success("Resume downloaded successfully");
    } catch (error) {
      console.error("Failed to generate PDF:", error);
      toast.error("Failed to generate PDF. Please try again.");
    } finally {
      setIsDownloading(false);
    }
  };

  return (
    <div className="flex flex-col gap-4 h-full">
      <div className="flex items-center justify-between">
        <h2 className="text-lg font-semibold">{title}</h2>
        {showDownload && (
          <Button
            variant="outline"
            size="sm"
            onClick={handleDownload}
            disabled={isDownloading}
          >
            {isDownloading ? (
              <>
                <Loader2 className="h-4 w-4 mr-2 animate-spin" />
                Generating...
              </>
            ) : (
              <>
                <Download className="h-4 w-4 mr-2" />
                Download PDF
              </>
            )}
          </Button>
        )}
      </div>
      <Card className="flex-1 overflow-hidden py-1">
        <CardContent className="p-0" style={{ height }}>
          <PDFViewerClient profile={profile} />
        </CardContent>
      </Card>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Custom Download vs PDFDownloadLink

I considered using @react-pdf/renderer's built-in PDFDownloadLink component, but went with custom logic because:

  1. Better UX — Custom loading states and toast notifications
  2. Error handling — Graceful fallback when PDF generation fails
  3. Filename control — Dynamic filename based on user's name
  4. Consistent styling — Matches the rest of the UI

After consolidating, I deleted resume-viewer.tsx and moved EmptyProfileState to its own file in the profile-editor directory. One less component to maintain.


Chapter 5: Live PDF Preview (And the Bug That Almost Broke It)

The Architecture

The profile editor has a split-screen layout: form on the right, live PDF preview on the left. When you save changes, the PDF updates immediately.

// components/profile-editor/profile-editor.tsx

export function ProfileEditor({ profile }: ProfileEditorProps) {
  const router = useRouter();

  const form = useAppForm({
    defaultValues: profileToFormValues(profile),
    onSubmit: async ({ value }) => {
      const response = await fetch("/api/profile", {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(value),
      });

      if (!response.ok) {
        throw new Error("Failed to update profile");
      }

      toast.success("Profile updated successfully");
      router.refresh(); // Revalidate server data
    },
  });

  return (
    <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
      {/* Left Side - PDF Preview */}
      <PDFPreview profile={profile} height="calc(100vh - 180px)" />

      {/* Right Side - Edit Form */}
      <div className="flex flex-col gap-4">{/* Form content */}</div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The "Eo is not a function" Bug

After implementing router.refresh() to update the PDF preview, I hit a cryptic error:

TypeError: Eo is not a function
    at PDFViewerClient (components/pdf/pdf-viewer-client.tsx:15:5)
Enter fullscreen mode Exit fullscreen mode

This is a known bug in @react-pdf/renderer. When the PDF document tree changes (sections added/removed), the internal reconciler crashes instead of gracefully updating. I found multiple GitHub issues describing the same problem.

The Fix: Force Remount on Data Change

The solution is to force the PDFViewer to completely remount when the profile changes, instead of trying to update in place:

// components/pdf/pdf-viewer-client.tsx

export function PDFViewerClient({ profile }: PDFViewerClientProps) {
  // Use updatedAt as a key to force remount on changes
  const profileKey = profile.updatedAt.toISOString();

  return (
    <PDFViewer
      key={profileKey}
      style={{
        width: "100%",
        height: "calc(100vh - 180px)",
        border: "none",
      }}
      showToolbar={false}
    >
      <ResumeTemplate key={profileKey} profile={profile} />
    </PDFViewer>
  );
}
Enter fullscreen mode Exit fullscreen mode

By using profile.updatedAt as the key, React treats it as a completely new component when the timestamp changes. The old PDFViewer unmounts, a fresh one mounts, and the bug never triggers.

Why router.refresh() Instead of State

My first attempt used a previewKey state variable:

// ❌ Before - stale data
const [previewKey, setPreviewKey] = useState(0);

// In onSubmit:
setPreviewKey((prev) => prev + 1);
Enter fullscreen mode Exit fullscreen mode

This re-rendered the component but with stale props. The profile data came from the server, and incrementing a key didn't fetch fresh data.

// ✅ After - fresh server data
router.refresh();
Enter fullscreen mode Exit fullscreen mode

router.refresh() triggers Next.js to revalidate the page's server data, ensuring the PDF preview shows the actual saved values.


Chapter 6: Production Readiness Fixes

Before shipping, I did a thorough code review and caught several issues:

1. SelectField Missing onBlur

// ❌ Before - validation errors don't show
<Select
  value={field.state.value}
  onValueChange={(value) => field.handleChange(value)}
>

// ✅ After - triggers validation on change
<Select
  value={field.state.value}
  onValueChange={(value) => {
    field.handleChange(value);
    field.handleBlur(); // Mark as touched
  }}
>
Enter fullscreen mode Exit fullscreen mode

Without handleBlur(), the field never gets marked as "touched," so validation errors don't appear until form submission. Users had no idea their selection was invalid.

2. DateField Crash on Malformed Data

// ❌ Before - crashes on invalid dates
const dateValue = parse(field.state.value, "yyyy-MM-dd", new Date());
return format(dateValue, "PPP"); // 💥 "Invalid Date"

// ✅ After - graceful fallback
const dateValue = field.state.value
  ? parse(field.state.value, "yyyy-MM-dd", new Date())
  : undefined;
const isValidDate = dateValue && !isNaN(dateValue.getTime());

return isValidDate ? format(dateValue, "PPP") : placeholder;
Enter fullscreen mode Exit fullscreen mode

Edge cases matter. Bad data from imports or migrations shouldn't crash the entire form.

3. Missing Fields in Section Components

During the review, I found that some section components were missing fields that existed in the Zod schema:

  • EducationSection was missing the location field
  • CertificationsSection was missing credentialUrl

This is exactly why Zod as single source of truth matters — but you still have to render all the fields!


The Final Architecture

components/
├── form/
│   ├── form-base.tsx          # Field wrapper (label, error, description)
│   ├── form-fields.tsx        # TextField, DateField, StringArrayField, etc.
│   └── form-components.tsx    # SubmitButton
├── pdf/
│   ├── pdf-preview.tsx        # Shared wrapper with download button
│   ├── pdf-viewer-client.tsx  # PDFViewer with remount fix
│   └── resume-template.tsx    # ATS-compliant PDF template
└── profile-editor/
    ├── profile-editor.tsx     # Main component with tabs
    ├── empty-profile-state.tsx # Shown when no profile exists
    └── sections/
        ├── basic-info-section.tsx
        ├── work-experience-section.tsx
        ├── education-section.tsx
        ├── skills-section.tsx
        ├── projects-section.tsx
        ├── certifications-section.tsx
        ├── languages-section.tsx
        ├── achievements-section.tsx
        └── social-links-section.tsx

lib/
└── validations/
    └── profile-update.ts      # Zod schemas + inferred types + factory functions

hooks/
├── form.tsx                   # useAppForm, withForm
└── form-context.tsx           # TanStack Form contexts
Enter fullscreen mode Exit fullscreen mode

TL;DR

Problem Solution
Type definitions in 3 places Zod schemas as single source, z.infer<> for types
Repetitive array field boilerplate withForm HOC + reusable section components
String arrays (bullets, highlights) Custom StringArrayField component
Ugly native date inputs shadcn Calendar with Popover
Duplicate PDF preview code Shared PDFPreview component
PDF crashes on data change Force remount with key={updatedAt}
Stale PDF after save router.refresh() for server revalidation
SelectField validation not showing Add handleBlur() to onValueChange
DateField crash on bad data isValidDate check before formatting

Key Lessons

1. Zod is more than validation.

Using z.infer<typeof Schema> eliminates an entire category of bugs. One schema, one type, zero drift. Delete your types.ts file.

2. Factory functions prevent undefined errors.

createEmptyWorkExperience() returns a complete object with all fields. No more "cannot read property of undefined."

3. @react-pdf/renderer has quirks.

The "Eo is not a function" error is a known issue with dynamic content. Force remounting with a key prop is the workaround.

4. Consolidate duplicate code early.

I had PDF preview logic in two places. When I fixed a bug in one, I forgot the other. Extract shared components before this happens.

5. router.refresh() is powerful.

It revalidates server data without a full page reload. Perfect for updating previews after mutations.

6. Edge cases crash production.

Invalid dates, missing fields, malformed data — test with bad inputs, not just happy paths.


What's Next

  • Form validation feedback — Show inline errors as users type
  • Autosave — Debounced saves to prevent data loss
  • Resume templates — Multiple PDF layouts to choose from
  • AI-powered suggestions — Use the resume analyzer to suggest improvements inline

Why Open Source?

Every refactor, every bug fix, every "I can't believe I missed that" moment — it's all in the repo.

Building forms with dynamic arrays is hard. Building them with type safety is harder. If this post saves you from the "Eo is not a function" rabbit hole or helps you structure your Zod schemas better, it was worth writing.

The repo: github.com/ImAbdullahJan/carific.ai

If you find this useful, consider starring the repo — it helps others discover the project!

Key files from this post:

  • lib/validations/profile-update.ts — Zod schemas and factory functions
  • hooks/form.tsx — TanStack Form setup with custom components
  • components/form/form-fields.tsx — All reusable field components
  • components/profile-editor/ — The complete profile editor
  • components/pdf/pdf-preview.tsx — Shared PDF preview component
  • components/pdf/pdf-viewer-client.tsx — PDF viewer with remount fix

Your Turn

I'd love feedback:

  • On the code: See something that could be better? Open an issue or PR.
  • On the post: Too long? Missing something? Tell me.
  • On forms: How do you handle complex forms with dynamic arrays?

Building in public only works if there's a public to build with.


If this post helped you, drop a ❤️. It means more than you know.


Let's connect:

Fourth post of many. See you in the next one.

Top comments (1)

Collapse
 
shariq_dd8f2e45b2d21e8505 profile image
Shariq

It's great to see you grow!