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 dataaction()
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
-
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
-
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
-
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
- When the form submits,
-
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
-
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
-
Shared Schema:
- Both client and server use the same Zod schema
- This guarantees consistent validation rules
- TypeScript types are automatically inferred
-
Error Handling:
- Client-side errors show immediately
- Server-side errors are returned to the client
- Conform automatically displays them in the right fields
-
Type Safety:
- The
UserFormData
type is derived from the Zod schema - This type flows through the entire stack
- From form fields to database operations
- The
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:
- A strong type system (TypeScript)
- Runtime validation (Zod)
- Form handling (Conform)
These three pillars can be implemented with various tools while maintaining the same level of type safety and developer experience.