Dynamic AI Form Generation
Jan 3, 2026 • 20 min read
Sometimes the best AI output isn't text — it's a pre-filled, editable form. "Book me a flight" shouldn't return a confirmation message; it should render a flight booking form with destination, date, and passengers pre-filled from the conversation context. The user reviews, corrects if needed, and submits. This Human-in-the-Loop pattern builds user trust for AI taking real actions and ensures errors are caught before they become expensive mistakes.
1. The Human-in-the-Loop Pattern
The workflow: User natural language → LLM extracts structured intent → Form renders with AI-populated defaults → User edits as needed → Server action executes with validated data.
2. Zod Schema: The Shared Contract
// schemas/ticket.ts — Shared between client form and AI tool definition
import { z } from 'zod';
export const TicketSchema = z.object({
title: z.string()
.min(5, "Title must be at least 5 characters")
.max(100, "Title too long"),
description: z.string()
.min(10, "Please provide more detail"),
priority: z.enum(['Low', 'Medium', 'High', 'Critical']),
assignee: z.string().email("Invalid email address"),
due_date: z.string().regex(/^d{4}-d{2}-d{2}$/, "Format: YYYY-MM-DD"),
labels: z.array(z.string()).max(5, "Maximum 5 labels"),
estimate_hours: z.number().min(0.5).max(500).optional(),
});
export type TicketFormData = z.infer<typeof TicketSchema>;
// This same Zod schema is used for:
// 1. Client-side validation in React Hook Form
// 2. OpenAI structured output schema (via zodResponseFormat)
// 3. Server-side re-validation before database write
// Single source of truth = no schema drift between layers3. React Hook Form with AI-Generated Defaults
// components/TicketForm.tsx
'use client';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { TicketSchema, TicketFormData } from '@/schemas/ticket';
import { useState } from 'react';
interface TicketFormProps {
defaultValues: Partial<TicketFormData>; // AI-populated defaults
onSubmit: (data: TicketFormData) => Promise<void>; // Server action
}
export function TicketForm({ defaultValues, onSubmit }: TicketFormProps) {
const [submitStatus, setSubmitStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const form = useForm<TicketFormData>({
resolver: zodResolver(TicketSchema),
// AI-provided defaults pre-fill the form
defaultValues: {
priority: 'Medium', // Fallback defaults
labels: [],
...defaultValues, // AI overrides fallbacks where it has data
},
});
const handleSubmit = async (data: TicketFormData) => {
setSubmitStatus('submitting');
try {
await onSubmit(data);
setSubmitStatus('success');
} catch (error) {
setSubmitStatus('error');
}
};
const { errors, isSubmitting } = form.formState;
return (
<form onSubmit={form.handleSubmit(handleSubmit)} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{/* Title field */}
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.9rem', fontWeight: 'bold' }}>
Title *
</label>
<input
{...form.register('title')}
style={{ width: '100%', padding: '0.5rem', background: 'var(--bg-tertiary)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: 'inherit' }}
/>
{errors.title && <p style={{ color: '#f87171', fontSize: '0.8rem', marginTop: '0.25rem' }}>{errors.title.message}</p>}
</div>
{/* Priority dropdown */}
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.9rem', fontWeight: 'bold' }}>Priority</label>
<select
{...form.register('priority')}
style={{ padding: '0.5rem', background: 'var(--bg-tertiary)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: 'inherit' }}
>
{['Low', 'Medium', 'High', 'Critical'].map(p => (
<option key={p} value={p}>{p}</option>
))}
</select>
</div>
{/* Description textarea */}
<div>
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.9rem', fontWeight: 'bold' }}>Description *</label>
<textarea
{...form.register('description')}
rows={4}
style={{ width: '100%', padding: '0.5rem', background: 'var(--bg-tertiary)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: 'inherit', resize: 'vertical' }}
/>
{errors.description && <p style={{ color: '#f87171', fontSize: '0.8rem' }}>{errors.description.message}</p>}
</div>
{submitStatus === 'success' ? (
<div style={{ color: '#22c55e', padding: '0.5rem', background: 'rgba(34,197,94,0.1)', borderRadius: '6px' }}>
✅ Ticket created successfully!
</div>
) : (
<button
type="submit"
disabled={isSubmitting}
style={{ padding: '0.75rem', background: isSubmitting ? 'var(--bg-tertiary)' : 'var(--primary)', border: 'none', borderRadius: '8px', cursor: isSubmitting ? 'not-allowed' : 'pointer', fontWeight: 'bold' }}
>
{isSubmitting ? 'Creating ticket...' : 'Create Ticket'}
</button>
)}
</form>
);
}4. Server Action: AI Extraction and Re-Validation
// actions.tsx (Vercel AI SDK RSC)
'use server';
import { streamUI } from 'ai/rsc';
import { openai } from '@ai-sdk/openai';
import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod';
import { TicketSchema, TicketFormData } from '@/schemas/ticket';
import { TicketForm } from '@/components/TicketForm';
export async function submitUserMessage(userInput: string) {
const ui = await streamUI({
model: openai('gpt-4o'),
system: "You are a project management assistant. Extract ticket details from user requests.",
messages: [{ role: 'user', content: userInput }],
tools: {
create_ticket: {
description: 'Create a Jira/GitHub issue ticket. Show a confirmation form first.',
parameters: z.object({
title: z.string().describe('Concise ticket title'),
description: z.string().describe('Detailed description of the issue or task'),
priority: z.enum(['Low', 'Medium', 'High', 'Critical']).describe('Ticket priority based on urgency and impact'),
assignee: z.string().email().describe('Email of the person to assign this to').optional(),
labels: z.array(z.string()).describe('Relevant labels/tags').optional(),
}),
generate: async function* (aiExtractedData) {
// Show loading state immediately
yield <div style={{ color: 'var(--text-secondary)' }}>Preparing confirmation form...</div>;
// Server action that's called when user submits the form
const handleTicketSubmit = async (formData: TicketFormData) => {
'use server';
// CRITICAL: Always re-validate on server — never trust client data
const validatedData = TicketSchema.parse(formData);
// Execute the actual API call only after server validation passes
const ticket = await fetch('https://api.linear.app/graphql', {
method: 'POST',
headers: { 'Authorization': process.env.LINEAR_API_KEY! },
body: JSON.stringify({
query: `mutation CreateIssue($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { id title url } } }`,
variables: { input: validatedData }
})
}).then(r => r.json());
return ticket.data.issueCreate.issue;
};
return (
<div style={{ border: '1px solid rgba(255,255,255,0.1)', borderRadius: '12px', padding: '1.5rem' }}>
<h3 style={{ marginTop: 0 }}>Review Ticket Details</h3>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.9rem' }}>
Please review the extracted details and edit anything before creating.
</p>
<TicketForm
defaultValues={aiExtractedData} // AI-populated defaults
onSubmit={handleTicketSubmit}
/>
</div>
);
},
},
},
text: ({ content }) => <div>{content}</div>,
});
return { display: ui.value };
}5. Streaming Partial Forms with useObject
// For very large forms: stream the form data progressively
// Fields become interactive as the AI generates them
import { experimental_useObject as useObject } from 'ai/react';
export function StreamingTicketForm({ userInput }: { userInput: string }) {
const { object, submit, isLoading } = useObject({
api: '/api/generate-ticket', // Route handler that streams Zod-structured output
schema: TicketSchema,
});
// Submit triggering streaming generation
const handleGenerate = () => submit({ prompt: userInput });
return (
<div>
<button onClick={handleGenerate} disabled={isLoading}>
{isLoading ? 'AI is filling form...' : 'Generate Form from Description'}
</button>
{/* Fields render and become interactive AS the AI generates them */}
<input
value={object?.title ?? ''} // Updates in real-time as AI generates
placeholder={isLoading ? 'Generating title...' : 'Title'}
readOnly={isLoading} // Lock while generating, editable when done
/>
<textarea
value={object?.description ?? ''}
placeholder={isLoading ? 'Generating description...' : 'Description'}
readOnly={isLoading}
/>
{/* User can verify the title before description finishes generating! */}
</div>
);
}Frequently Asked Questions
Why re-validate on the server if Zod validates on the client?
Client-side validation is UX convenience, not security. A malicious user can bypass client validation entirely by sending crafted requests directly to your server action endpoint, bypassing the form and React Hook Form completely. Always treat all data received in server actions as untrusted, validate with Zod server-side before any database writes or API calls, and implement authorization checks to verify the authenticated user has permission to perform the action. The pattern: const safe = TicketSchema.parse(formData) at the start of every server action that writes data.
How do I handle discriminated union forms where field sets change based on a type selector?
Use Zod discriminated unions with React Hook Form's watch: const vehicleType = form.watch('type') and conditionally render fields based on its value. Define the schema as z.discriminatedUnion('type', [z.object({type: z.literal('car'), license_plate: z.string()}), z.object({type: z.literal('boat'), hull_id: z.string()}) ]). The LLM respects discriminated unions excellently when you export the schema as JSON Schema (using zodToJsonSchema) in your tool definition — it learns which fields are valid for each type.
Conclusion
AI form generation combines the speed of LLM data extraction with the safety of human review. By sharing a single Zod schema between the AI tool definition, client validation, and server-side verification, you maintain a consistent contract across all layers with no schema drift. The Human-in-the-Loop pattern — AI drafts, human approves — is the right architecture for any AI action with real-world consequences. Streaming partial forms with useObject makes even complex multi-field forms feel instantaneous, with fields becoming interactive before the AI has finished generating the complete response.
Vivek
AI EngineerFull-stack AI engineer with 4+ years building LLM-powered products, autonomous agents, and RAG pipelines. I've shipped AI features to production for startups and worked hands-on with GPT-4o, LangChain, LlamaIndex, and the Vercel AI SDK. I started OpnCrafter to share everything I wish I had when learning — no fluff, just working code and real-world context.
Continue Reading
Vivek
AI EngineerFull-stack AI engineer with 4+ years building LLM-powered products, autonomous agents, and RAG pipelines. I've shipped AI features to production for startups and worked hands-on with GPT-4o, LangChain, LlamaIndex, and the Vercel AI SDK. I started OpnCrafter to share everything I wish I had when learning — no fluff, just working code and real-world context.