Skip to main content

Vite Plugin

@zod-to-form/vite turns Zod schemas into rendered React forms with a single import — no codegen CLI step, no committed generated files. The plugin intercepts a special ?z2f import suffix at build time, walks the referenced schema, and produces a fully-formed React component as a virtual module.

When to Use

Use the Vite plugin path when:

  • The project is already built with Vite (React, Solid via JSX, etc.)
  • You want forms in your source tree to stay automatically in sync with Zod schemas without committing generated files
  • HMR-driven development is more important than hand-readable static output
  • You'd rather not run a separate zod-to-form generate step on every schema change

The plugin coexists with the CLI: a project may commit some generated forms via the CLI and import others through ?z2f. Each path is independent.

Prerequisites

  • Node.js >= 20
  • Zod v4 (zod@^4.0.0) — Zod v3 is not supported
  • A Vite project (vite@^5 || ^6 || ^7 || ^8)
  • React 18+ with react-hook-form and @hookform/resolvers

Install

pnpm add -D @zod-to-form/vite
# or npm install --save-dev @zod-to-form/vite
# or yarn add --dev @zod-to-form/vite

Register the plugin

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import z2fVite from '@zod-to-form/vite';

export default defineConfig({
plugins: [
z2fVite(),
react()
]
});

Plugin order matters. Put z2fVite() before react() so the plugin's generated TSX goes through React's JSX transform normally.

Add TypeScript declarations

Add to your tsconfig.json:

{
"compilerOptions": {
"types": ["@zod-to-form/vite/client"]
}
}

That's it — no .d.ts files to author yourself.

Write a schema

// src/schemas/signup.ts
import { z } from 'zod';

export const signupSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['admin', 'user', 'guest'])
});

Import and render the generated form

// src/App.tsx
import SignupForm from './schemas/signup.ts?z2f';

export default function App() {
return (
<SignupForm
onSubmit={(data) => {
// `data` is typed from the schema
console.log(data);
}}
/>
);
}

Use a default import. The ?z2f virtual module exports the generated component both as default and as a named export, but only the default export is typed by the @zod-to-form/vite/client ambient declarations. The runtime supports either style.

Start the dev server with pnpm dev. The form renders immediately — no codegen CLI step, no committed generated files.

What happened

  • Vite saw import ... from './schemas/signup.ts?z2f'
  • @zod-to-form/vite intercepted the import in its resolveId hook and generated a virtual module containing a fully-formed React form component for signupSchema
  • React's JSX transform compiled the generated TSX to JS
  • The form rendered

HMR

Edit a schema file in dev mode and the plugin re-compiles only the affected form:

// Add a field
export const signupSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['admin', 'user', 'guest']),
age: z.number().int().min(13) // ← new
});

Vite's HMR pushes the new bundle to the browser — <SignupForm> re-renders with an additional age field, and form state for the existing fields is preserved through React Fast Refresh.

Variants — multiple forms from one schema

Declare a z2f.config.ts in your project root:

// z2f.config.ts
export default {
componentName: 'UserForm',
mode: 'submit',
ui: 'html',
variants: {
edit: { componentName: 'UserEditForm' },
create: { componentName: 'UserCreateForm', ui: 'shadcn' }
}
};

Then import each variant via ?z2f=<variant>:

import UserEditForm from './schemas/user.ts?z2f=edit';
import UserCreateForm from './schemas/user.ts?z2f=create';

The plugin auto-discovers z2f.config.{ts,mts,js,mjs} in the Vite root. When the config file changes, the plugin's HMR hook invalidates every cached form so the next request reflects the new config. A syntax error in the config keeps the previous valid version serving — the dev server stays alive.

Build for production

pnpm build

The plugin's load hook fires during the production build the same way it fires in dev. The generated forms are inlined into the bundle, and there's no runtime dependency on @zod-to-form/core or @zod-to-form/react for the generated path — bundlers tree-shake those packages out.

Validation optimization (resolver tree-shake)

When validationLevel is set in the plugin config, the build pass strips every zodResolver(...) call from useZodForm and removes the unused @hookform/resolvers/zod import. The optimized bundle is materially smaller:

// vite.config.ts
import z2fVite from '@zod-to-form/vite';

export default {
plugins: [
z2fVite({
configOverride: {
validationLevel: 2
}
})
]
};

In dev mode the resolver path stays intact for fast iteration; the strip only fires during vite build.

Generate mode (opt-in)

Generate mode scans your JSX for <ZodForm schema={X} /> runtime call sites and rewrites them at build time into the generated component for X. You get static codegen with zero source-code changes:

// vite.config.ts
import z2fVite from '@zod-to-form/vite';

export default {
plugins: [
z2fVite({
generate: {} // presence opts in
})
]
};

Generate mode is off by default because it silently changes compiled output. The transform hook only matches <ZodForm> JSX whose schema={...} prop is an Identifier resolvable to a top-level named import inside the Vite root. Anything dynamic, conditional, aliased, or imported from node_modules is left as a runtime call and reported in the build-end summary.

A typical summary at info level:

[@zod-to-form/vite] Generate mode processed 42 files, rewrote 38 call sites, skipped 4:
src/App.tsx:22:5 — schema prop is dynamic (JSXExpressionContainer not an Identifier)
src/Admin.tsx:8:3 — schema identifier resolves to node_modules package '@acme/schemas'

See the generate-mode contract for the full match-criteria table.

Plugin options

OptionTypeDefaultDescription
configPathstring(auto-discover)Explicit path to a z2f.config.{ts,mts,js,mjs} file
configOverridePartial<Z2FViteConfig>{}Shallow override merged on top of the loaded config
generate{ include?, exclude? }undefinedPresence enables generate mode; include/exclude are glob patterns
write{ outDir?, filenamePattern? }undefinedPersist generated files to disk in addition to the virtual modules
logLevel'silent' | 'warn' | 'info' | 'debug''info'Plugin-specific log level

Coexistence with the CLI

A project may use both the CLI (committed *.generated.tsx files) and the Vite plugin (?z2f virtual modules) simultaneously. The plugin only handles imports that carry ?z2f (or, in generate mode, JSX call sites that match the strict <ZodForm schema={X}> shape); CLI-emitted files are never touched.

Troubleshooting

  • Z2F_VITE_AMBIGUOUS_EXPORT — the schema file has multiple Zod schema exports. Set exportName in your z2f.config.ts (or in a variant) to pick one.
  • Z2F_VITE_SCHEMA_OUTSIDE_ROOT — the resolved schema path is outside your Vite root. Move it into the project, or use the CLI for cross-project schemas.
  • Z2F_VITE_UNKNOWN_VARIANT — you imported ?z2f=foo but foo isn't declared in config.variants. Add it, or drop the variant suffix.
  • A specific JSX site isn't being rewritten — run dev with logLevel: 'debug' or build with logLevel: 'info' and check the generate-mode summary for the per-site reason.

What's next