Creating Type-Safe Full Stack Forms

May 19, 2025

Web, Javascript, Forms and Types

Forms are one of the trickiest things to keep safe, reliable, and fully typed — especially when they involve both client and server logic. Drawing inspiration from the Epic Stack's approach to type-safe full-stack development, this guide demonstrates how to build robust, maintainable forms.

In this guide, we'll walk through building a fully typed, full-stack "Edit User" form at the route /users/:id. It will:

  • Load the user data by id
  • Prefill the form with user info (name, email, phone)
  • Use a shared Zod schema for validation
  • Use Conform for client and server side validation and error binding
  • Use Prisma to fetch and update the user
  • Be fully typed from end to end

Stack Overview

Make sure you have the following packages installed:

npm install react-router-dom zod @conform-to/react @conform-to/zod

This stack gives us:

Zod

Zod is a TypeScript-first schema declaration and validation library. It lets us:

  • Define reusable, composable validation schemas
  • Infer TypeScript types automatically
  • Validate on the client and the server with one shared schema

This ensures your data is always validated exactly the same way, regardless of where it's coming from.

Conform

Conform makes working with forms easier by:

  • Auto-wiring input values and error messages from Zod
  • Handling onBlur/onSubmit validation elegantly
  • Making your forms declarative, accessible, and consistent

It removes a ton of repetitive boilerplate and encourages unified, elegant form handling.

React Router 7 (Data APIs)

React Router 7 introduced a new data-driven model for routes:

  • loader() functions for fetching data
  • action() functions for handling submissions
  • Automatic error handling, revalidation, and transitions

And best of all: it's all type-safe. With the right return values, you get full intellisense and type support in your components.

Prisma

Prisma is a type-safe ORM for Node.js and TypeScript:

  • Generates types from your database schema
  • Guarantees type safety for queries and mutations
  • Integrates cleanly into any backend

It completes the chain — from DB to UI — with full confidence that the shape of your data is always known.

Define the User Schema with Zod

Create a shared schema file:

// schemas/user.ts import { z } from 'zod'; export const userSchema = z.object({ id: z.string().min(1, 'User ID is required'), name: z.string().min(1, 'Name is required'), email: z.string().email('Invalid email'), phone: z .string() .min(10, 'Phone must be at least 10 digits') .optional() .or(z.literal('')), }); export type UserFormData = z.infer<typeof userSchema>;

Set Up Prisma

Assuming you have a simple user model:

// prisma/schema.prisma model User { id String @id @default(cuid()) name String email String @unique phone String? }

React Router Route Structure

In your routes/users/$id.tsx file, set up the loader, action, and component.

Loader – Fetch User by ID

// routes/users/$id.tsx import { LoaderFunctionArgs, json } from 'react-router-dom/server'; import { prisma } from '../../db'; export async function loader({ params }: LoaderFunctionArgs) { const user = await prisma.user.findUnique({ where: { id: params.id }, }); if (!user) { throw new Response('User not found', { status: 404 }); } return json({ user }); }

The EditUserForm Component

import { Form, useActionData, useLoaderData, useNavigation, } from 'react-router-dom'; import { useForm } from '@conform-to/react'; import { getFieldsetConstraint, parse } from '@conform-to/zod'; import { userSchema, UserFormData } from '../../schemas/user'; export default function EditUserRoute() { const { user } = useLoaderData() as { user: UserFormData }; const lastResult = useActionData<typeof action>(); const navigation = useNavigation(); const [form, fields] = useForm<UserFormData>({ id: 'edit-user-form', defaultValue: user, lastResult, constraint: getFieldsetConstraint(userSchema), onValidate({ formData }) { return parse(formData, { schema: userSchema }); }, shouldValidate: 'onBlur', }); return ( <Form method="post" {...form.props}> <input type="hidden" {...fields.id} /> <div> <label> Name <input {...fields.name} /> {fields.name.errors && <p>{fields.name.errors[0]}</p>} </label> </div> <div> <label> Email <input type="email" {...fields.email} /> {fields.email.errors && <p>{fields.email.errors[0]}</p>} </label> </div> <div> <label> Phone <input type="tel" {...fields.phone} /> {fields.phone.errors && <p>{fields.phone.errors[0]}</p>} </label> </div> <button type="submit" disabled={navigation.state= 'submitting'}> Save </button> </Form> ); }

Action – Validate and Save Edits

Note: The custom server-side validations shown below (like checking for unique emails or allowed domains) are not strictly necessary for a simple edit form. They are included here to demonstrate how you can enforce business rules and type safety on the server. In many real-world cases, you may only need basic validation, but this pattern shows how to extend your validation logic as needed.

import { ActionFunctionArgs, json, redirect } from 'react-router-dom/server'; import { parseWithZod } from '@conform-to/zod'; import { userSchema } from '../../schemas/user'; export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); // Create a refined schema with server-side validations const serverSchema = userSchema.superRefine(async (data, ctx) => { // Check if user exists const existingUser = await prisma.user.findUnique({ where: { id: data.id }, }); if (!existingUser) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'User not found', path: ['id'], }); return; } // Check if email is already taken by another user const emailExists = await prisma.user.findFirst({ where: { email: data.email, id: { not: data.id }, }, }); if (emailExists) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email is already taken', path: ['email'], }); return; } // Check if email domain is allowed const allowedDomains = ['company.com', 'partner.com']; const emailDomain = data.email.split('@')[1]; if (!allowedDomains.includes(emailDomain)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email domain not allowed', path: ['email'], }); return; } }); const submission = await parseWithZod(formData, { schema: serverSchema, async: true, // Enable async validation }); if (!submission.value || submission.intent !== 'submit') { return json(submission, { status: 400 }); } const { id, name, email, phone } = submission.value; // Proceed with update if all validations pass await prisma.user.update({ where: { id }, data: { name, email, phone: phone || null }, }); return redirect(`/users/${id}`); }

Client and Server Validation Flow

The beauty of this setup is how it handles validation at both ends while maintaining a single source of truth. Let's break down how it works:

Client-Side Validation

  1. Conform's useForm Hook:

    • Sets up form state management
    • Handles field-level validation on blur (shouldValidate: 'onBlur')
    • Manages error states and field values
    • Provides type-safe field props through the fields object
  2. Real-time Validation:

    • As users type or blur fields, Conform runs the Zod schema validation
    • Errors appear immediately under each field
    • The form won't submit if there are validation errors
    • All of this happens without a server roundtrip

Server-Side Validation

  1. Action Function:

    const submission = parseWithZod(formData, { schema: userSchema });
    • When the form submits, parseWithZod runs the same Zod schema
    • This ensures server-side validation matches client-side exactly
    • If validation fails, the submission is rejected before hitting the database
  2. Double Safety:

    • Even if someone bypasses client-side validation
    • Or if the client-side code is modified
    • The server will still enforce the same rules
  3. Deeper Server-Side Validation with Conform:

    import { refine } from '@conform-to/zod'; import { z } from 'zod'; export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); // Create a refined schema with server-side validations const serverSchema = userSchema.superRefine(async (data, ctx) => { // Check if user exists const existingUser = await prisma.user.findUnique({ where: { id: data.id }, }); if (!existingUser) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'User not found', path: ['id'], }); return; } // Check if email is already taken by another user const emailExists = await prisma.user.findFirst({ where: { email: data.email, id: { not: data.id }, }, }); if (emailExists) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email is already taken', path: ['email'], }); return; } // Check if email domain is allowed const allowedDomains = ['company.com', 'partner.com']; const emailDomain = data.email.split('@')[1]; if (!allowedDomains.includes(emailDomain)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Email domain not allowed', path: ['email'], }); return; } }); const submission = await parseWithZod(formData, { schema: serverSchema, async: true, // Enable async validation }); if (!submission.value || submission.intent !== 'submit') { return json(submission, { status: 400 }); } const { id, name, email, phone } = submission.value; // Proceed with update if all validations pass await prisma.user.update({ where: { id }, data: { name, email, phone: phone || null }, }); return redirect(`/users/${id}`); }

    This approach offers several advantages:

    • All validation logic is centralized in the schema
    • Validation errors are properly associated with their fields
    • The form component automatically displays the errors
    • Type safety is maintained throughout
    • Async validation is handled elegantly
    • Error messages are properly structured for the UI

    The errors will automatically be bound to the correct form fields in your component:

    <input type="email" {...fields.email} /> {fields.email.errors && <p>{fields.email.errors[0]}</p>}

How Conform and parseWithZod Work Together

  1. Shared Schema:

    • Both client and server use the same Zod schema
    • This guarantees consistent validation rules
    • TypeScript types are automatically inferred
  2. Error Handling:

    • Client-side errors show immediately
    • Server-side errors are returned to the client
    • Conform automatically displays them in the right fields
  3. Type Safety:

    • The UserFormData type is derived from the Zod schema
    • This type flows through the entire stack
    • From form fields to database operations

This dual-layer validation ensures your data is always valid, whether it's coming from a legitimate user or a malicious request. The combination of Conform and parseWithZod creates a seamless, type-safe experience while maintaining security.

Full Type Safety Recap

Here's how type safety flows through each layer of our application:

  • Form inputs: Typed with useForm<UserFormData>()
  • Validation: Enforced by Zod with @conform-to/zod
  • Client binding: Conform auto-binds inputs and errors
  • Loader return: Typed from loader()
  • Action parsing: Typed from parseWithZod()
  • DB Update: Typed from Prisma schema

This harmony of tools creates a completely typed loop from database to form and back. And if anything gets out of sync, TypeScript will catch it.

Conclusion

With React Router 7, Conform, Zod, and Prisma, you can build forms that are:

  • Type-safe across client/server boundaries
  • Reusable and declarative
  • Validated in one place
  • Easy to maintain

Zod makes sure your types and validations are in one place. Conform gives you accessible, ergonomic forms with rich feedback. React Router 7 brings a powerful, type-safe way to handle data. Prisma ties your database to your types seamlessly.

No more guessing what's going back and forth between the browser and server — it's all typed.

Flexibility and Adaptability

While I've used React Router 7 and Prisma in this example, it's important to note that this pattern is highly adaptable to different tech stacks. The core principles remain the same regardless of your specific tools:

  • Different Routing Solutions: Whether you're using Next.js, Remix, or a custom API layer, the pattern of type-safe data loading and form submission remains valid. The key is ensuring your data fetching layer (loader, getServerSideProps, etc.) returns properly typed data.

  • Alternative Databases: Prisma is just one way to achieve type-safe database operations. You could use TypeORM, Drizzle, or even a REST API with proper TypeScript types. The important part is maintaining type safety between your database and frontend.

  • Separate Backend: This approach works equally well with a separate backend service. You just need to:

    • Define your API types (using tools like OpenAPI/Swagger or manual TypeScript types)
    • Ensure your frontend data fetching layer properly types the responses
    • Use Zod to validate the data at the API boundary

The real power comes from the combination of:

  1. A strong type system (TypeScript)
  2. Runtime validation (Zod)
  3. Form handling (Conform)

These three pillars can be implemented with various tools while maintaining the same level of type safety and developer experience.