All right, so here's how I do it. I'm going to use the mpx command to run the create-next-app package at the latest version to get the latest code. And then I'm going to use this use-pmpm option so that it uses pmpm as the package manager as opposed to npm. I'm a pmpm fan, and this will just make sure
that all the dependencies are installed using pmpm. So I'm going to name my project AppRouterForms. You can name it whatever you want. I'm definitely going to use TypeScript. You can use ESLint if you want or not. I'm definitely going to choose Tailwind because ShadCN relies on Tailwind. I'm going to choose to use the source directory.
That puts all of the source for the entire application in the source directory within the top level. So that keeps the top level of the app really clean. I'm definitely going to choose to use the AppRouter. It's recommended. And I'm not going to customize my import alias. This just means that @, if I use it in import,
will be relative to the source directory. All right, I'm going to bring that up in VS Code. The next thing we need to do is initialize ShadCN. So I'm going to go to my console. And then I'm going to initialize ShadCN using the mpx command again, this time with the ShadCN UI package.
And the init command, that's going to initialize the project so that it's ready to get any components we want to bring in from ShadCN. We get some options here, like the style that we want. I'm just going to pick the defaults. And then I'm going to say that I want to use CSS variables for colors. And we're initialized.
Now let's actually go take a look at what's happened. So it's gone and created a components.json file in our root directory. That's basically for this mpx ShadCN command to know where everything is, what my style preferences are, and all that. It's gone and made some changes to the tailwind configuration file to add CSS variables.
And then it's defined those CSS variables over in our globals.css, with the actual colors. Back at the top level, let's look at package.json. Now we can see it's already added some libraries, just to support the basic shell of ShadCN. So now let's go and add in our first components.
Now the most critical one we want to add is the form system. So we're going to use mpx again, and then ShadCN UI. In this case, we're going to use add instead of init. We're already inited. And we're just going to give it the name of the component system we want, which in this case is form. Now I say component system, because form actually brings in a bunch
of components out of the box. You can see your ShadCN components over in the components UI directory. In this case, it's brought in button, form, and label. Now the interesting thing about ShadCN is that you can actually edit these files. They're there to be edited and customized in your project.
It's not like other UI libraries, where you can't actually edit the components. Here, they actually just copy it in your project, and you can make the changes. I'm not going to make any changes, but you can if you want to. Now it's interesting. Having added form, ShadCN automatically added some new dependencies
for us, including React Quick Form to manage our forms, resolvers, which allow you to connect schema validation libraries like Zod or Joy or AGV to React Quick Form. In this case, it also brought in Zod, so we're going to use Zod to define our schema.
Now the format we're going to build has only input controls, so you also need to bring in the input component. To do that, we use the exact same ShadCN command. We'll just bring in input. And there we go. We've got our input field. Now that our application is configured, let's bring it up in development mode.
To do that, I use pnpm and then dev. It's got a stark white background. I want to work in dark mode, so the first thing I'm going to do is set the body to dark. To do that, I go to the app and then global CSS, and then add body, and then apply the tailwind for dark.
That applies the dark mode theme. Let's go take a look in browser. And now everything's dark. Of course, I've got all this boilerplate in there, so let's get rid of all that. To do that, we just go into page.tsx. And then we remove everything and replace it with a div.
We go back to our page, nice and blank and ready to go, ready for our form. So we're going to build a user registration form. It's going to have a first name, last name, and an email field. And the first thing we want to do is build a schema for that that we can validate any user input against.
So let's go and create a registration schema in a file called registration schema.tsx. Then we'll bring in Zod. Zod's what we're going to use to define our schema. Z is just an object. It's got a lot of little helper methods in it
that allow us to create a schema that we can then validate against. Now, our base object here is going to be an object. It's going to have keys for first, last, and email. So we use Z.object to define our object. And we export that as schema.
So let's define our first field. We'll say that first is going to be a type of string. So we use Z.string. Now, there's two types of things that you can do with Zod. You can have it clean up the data. In this case, we want to trim it down. So let's use .trim. Now, we'll trim any white space off the left and right-hand sides
of the string. And then we apply any validations we want. In this case, the validation we want to use is the minimum number of characters. We want to make sure that they've actually typed something in. So we say that we want at least one character. And if you fail that, then the message is that the first name is required.
So let's do the same thing for last. Exactly the same thing, except last instead of first. And we massage the message a little bit. And then finally, we want an email field. So we do pretty much the same thing. But instead of min, we use email for our validation. Now, we don't need to define any extra parameters on email.
In this case, we just need to say that the message is invalid email address if it turns out to be an invalid email address. Now that we've got that going, we actually want to create a registration form. That's going to be a client component. It's going to use a hook.
In this case, that is the use form hook from React hook form. But before we do that, let's just create a simple registration form. And it'll bring in use form. And then we'll invoke that within our registration form. So use form can take a lot of different parameters. The one that you often use is the default values.
So in this case, first, last, and email are going to be defaulted to an empty string. So now we want to connect that to our schema. Let's bring in our schema from the registration schema. Now, to connect those two, we need a resolver. So we're going to bring in the Zod resolver from Hook Form Resolvers. Hook Form Resolvers has resolvers
for all kinds of schema validation libraries. There are a bunch of them-- Joy, YUP, AJV. You can go and check out the full list on the Hook Form Resolvers NPM package. Now, down to use form, we set the resolver to the output of Zod resolver with our Zod schema.
Now, how do we know that first, last, and email are the right ones? We could just put in foo here. And that may or may not be right. So what we really want to do is tell use form what the structure of our schema is in TypeScript.
To do that, we use a template syntax after use form. And now we see that use form has applied that schema to the default values. And so foo is invalid. That's cool, but really, we don't want to have to type all this. We want this just to come from the schema. So is there a way to infer that from our Zod schema
and turn our Zod schema into TypeScript? Well, yes, there is. First thing we need to do is bring in Zod. And then we can create a new type called our schema, for example, by inferring using the z.infer utility type against the type of our schema.
And what that comes out with is that exact same thing. We've got first name, last name, and email, and everything's in TypeScript. Awesome. So we can use that here in place of what we had before. Or we just want to keep it nice and terse.
We can just put that in here instead of our schema. And again, foo is wrong, so let's get rid of that. Nice. Now our use form is configured to match the TypeScript schema of our registration form Zod schema. So let's start building out our form. So that brings in some UI components,
including button and input. We need the button in order to submit, and we need the input in order to take input from the user. And then we're going to bring in the form components from ShadCN. That includes form. That's a wrapper around the form. Form control, which is a wrapper around individual controls
within the form. And then a bunch of helper components like field, and item, and label, and so on and so forth. If you want to know more about this, there actually is excellent documentation on this. Over on the ShadCN site, let's go to form. So they got some excellent documentation on React Hook
form and how it integrates with ShadCN. This is only client side. We're going to cover both client side and server side. In this tutorial. But there is an example section down below that you should have a look at, where they cover different types of controls and how to use them. For example, checkbox, date picker, radio group, and so on. Let's go back to our code, and we'll
start building out our JSX for our component. So the first thing we want to do is bring in that form component, and then give it all of the output from our use form. That's going to give it all of the controls from use form, so that that component can then manage that form. But we do want a legit form tag inside of that.
Let's add a Submit button, so we can submit that form. And then let's add the email field. To do that, we're going to add the form field component. To add the email field, we add form field. We give it the control that we get from form.control. That allows this form field component
to manage this particular field. And then we give it the name of the field, which in this case is email. If I remove email, you can actually see that our pop-up hints to all of the available fields that we have. Don't bring back email. The last thing it needs is a render function. So render gets given field, and then you
get to specify how you want that actual field to be laid out and all the components you're going to use. In my case, I'm going to use a form item. And then inside the form item, I'm going to have a form label that has the label. Then the form control that has our input. And we give that form control, in this case the input, all of the output of field.
That's going to have the current value, on change, on blur, and all that good stuff. And then the description, which I'll just set to your email address. And then the message is going to have any validation messages. So I think this is good enough to actually see. Let's go back over to our page, and then bring this in,
and then use it. Let's see. All right, looking pretty good. Although it does take up the entire width of the screen. So I'm going to constrain that a little bit. Just going to set the maximum width of this particular div to XL. That's going to make it small. And then MX Auto is going to bring it in and justify it on both sides.
So let's hit Save and see how that looks. That's a lot more clean. OK, so now that we've got email going, it's up to you. You're going to go and add the first and last name fields to our form. You can check out how I did it in the next section.
To start, run the create-next-app package using the npx command. We'll use the "--use-pnpm" option to ensure all dependencies get installed using the pnpm package manager.
npx create-next-app@latest --use-pnpm
When the prompt appears, we'll name the project app-router-forms. Say "yes" to TypeScript, ESLint, Tailwind CSS, the src directory, and the App Router. However, we'll say "no" to customizing the default import alias. This will keep the top level of the app clean.
npx create-next-app@latest --use-pnpm
What is your project named? ... app-router-forms
Would you like to use TypeScript? ... Yes
Would you like to use ESLint? ... Yes
Would you like to use Tailwind CSS? ... Yes
Would you like to use 'src/ directory? ... Yes
Would you like to use App Router? (recommended) .. Yes
Would you like to customize the default import alias (@/%)? > No
The application setup will take a moment. Once it's done, open the project in your preferred code editor.
Next we're going to initialize shadcn to make it ready for any components we want to bring in.
Open a terminal, and with the npx command we'll install and initialize the latest version of shadcn-ui:
npx shadcn-ui@latest init
A prompt will appear asking for your style preferences. I'll choose the default style with Slate as the base color and CSS variables for colors.
Which style would you like to use? ... Default
Which color would you like to use as base? ... Slate
Would you like to use CSS variables for colors? ... Yes
After initialization, a components.json file will be created in the root directory. This file tells the shadcn command where everything in our project is and what our style preferences are:
// components.json
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
The shadcn initialization also makes changes to the tailwind.config.ts file to add CSS variables and defines those CSS variables in the src/app/globals.css file with the actual colors.
With shadcn set up, we can now add our first components.
The form system is the most critical component we need to add.
Use the npx shadcn@latest command again, but this time with the add option to specify the component system to be added. In this case, we'll add the form system:
npx shadcn-ui@latest add form
Form brings in a bunch of components out of the box, including button, form, and label, which can be found over in the components UI directory. Note that these files are editable allowing for customization according to your project needs, which isn't always the case with other UI libraries.
Along with the form component, shadcn automatically adds new dependencies for us. The react-hook-form library for form management and @hookform/resolvers to connect the Zod library for schema validation.
In addition to the form system, we're going to need an input component. Use the same command as before to bring in the input field:
npx shadcn-ui@latest add input
Once that's done, it's time to start the application in development mode using pnpm:
pnpm dev
Once the application is up and running, we'll make a few changes to the starter code.
The first thing we'll do is change the body to dark mode. Inside of src/app/globals.css we'll add the following to apply dark mode to the body:
The page will switch to dark mode, but there will still be boilerplate code to remove.
Inside of src/app/page.tsx we can clear out everything in the Home component and replace it with an empty div:
Now we have a blank slate to start building our form!
Our user registration form will include first name, last name, and an email field.
To build this, the first step is creating a schema to validate user input against.
Create a new file called registrationSchema.tsx inside of the src/app directory. At the top of the file, we'll import z from the Zod library, then we'll export a schema for validation by using Zod's object.
The first item we'll add to the schema will be called first for the first name. This will be a string type that we'll trim and validate to ensure it's at least one character long. If the input doesn't meet this requirement, we'll return a message that the first name is required. We can do the same for the last field:
The next field we'll add to the schema is email. This will be a string type that we'll validate as an email address. If the input doesn't meet this requirement, we'll return a message that the email address is invalid:
Now that the schema has been defined, we can move on to creating the registration form.
Create a new file at src/app/RegistrationForm.tsx and import the useForm hook from react-hook-form. The export will be the RegistrationForm component:
The useForm hook can take a lot of different parameters, but the one you'll use most often is defaultValues. In this case, we'll set the first, last, and email fields to an empty string, which matches the structure of our schema:
Once we've added the default values, we can connect the form to our schema. To do this, we'll import the schema from registrationSchema.tsx and the zodResolver from @hookform/resolvers/zod.
We'll then use the template syntax after useForm to specify the schema to the form:
Now our form is connected to the schema and ready to be built out with the appropriate fields. However, this is kind of a lot of typing.
We can simplify this by using the useForm hook to infer the type from the schema using Zod's infer utility method. This will allow the form to know the structure of our schema and build out the JSX components accordingly.
Start by importing z from Zod, then we'll create a new type called OurSchema that uses Zod's infer utility method to infer the type of our imported schema:
Hovering over OurSchema shows us the inferred types we expect:
Now that we know that z.infer works, instead of having the resolver option manually specified in useForm we can either use the zodResolver with the OurSchema type or just infer it directly:
With the form connected to the schema, we can now build out the JSX for our form component.
The first thing to do is import the Button, Input, and Form related components from their respective locations:
The shadcn docs have excellent documentation for working with React Hook Form on the client side. We'll look more at server-side validation later.
Inside the RegistrationForm component we'll return a Form component that will get the all of the output from the form we set up with useForm.
Inside the Form component we'll add an email field and a Submit button. The email field will have a control of form.control, and the name will be set to email. Here's the basic structure for now:
Next we need to add a render function to the FormField component. The render function allows us to specify how we want the field to be laid out and the components that we will use. In this case, we'll use a FormItem along with a FormLabel, then a FormControl that contains the Input. The Input takes care of its value, onChange, onBlur, etc. We'll also add a FormDescription and FormMessage that will display any validation errors:
This is a good stopping point to check our progress.
On the page, we can see that the Email form is being displayed:

However, the form is currently taking up the entire width of the screen. We can constrain this to make it smaller and more visually appealing by setting the maximum width of the Home component div to max-w-xl:
Now the form looks better on our page:

At this point, we have successfully set up an email field.
Your task is to add the first and last name fields to the form. You'll see how I did this in the next section!
// inside src/app/globals.css
body {
@apply dark;
}
// inside src/pages/index.tsx
export default function Home() {
return <div></div>;
}
import { z } from 'zod';
export const schema = z.object({
});
export const schema = z.object({
first: z.string().trim().min(1, {
message: 'First name is required'
}),
last: z.string().trim().min(1, {
message: 'Last name is required'
}),
});
export const schema = z.object({
first: z.string().trim().min(1, {
message: 'First name is required'
}),
last: z.string().trim().min(1, {
message: 'Last name is required'
}),
email: z.string().email({
message: 'Invalid email address'
}),
});
// inside of src/app/RegistrationForm.tsx
"use client";
import { useForm } from 'react-hook-form';
export const RegistrationForm = () => {
}
export const RegistrationForm = () => {
const form = useForm({
defaultValues: {
first: '',
last: '',
email: '',
}
});
}
import { zodResolver } from '@hookform/resolvers/zod';
import { schema } from './registrationSchema';
export const RegistrationForm = () => {
const form = useForm<{
first: string;
last: string;
email: string;
}>({
resolver: zodResolver(schema),
defaultValues: {
first: "",
last: "",
email: "",
},
});
};
import { z } from 'zod';
type OurSchema = z.infer<typeof schema>;
// hover over OurSchema
type OurSchema = {
first: string;
last: string;
email: string;
}
export const RegistrationForm = () => {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
first: "",
last: "",
email: "",
}
});
};
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
// inside RegistrationForm
return (
<Form {...form}>
<form className="space-y-8">
</form>
</Form>
)
// inside RegistrationForm
return (
<Form {...form}>
<form className="space-y-8">
<FormField control={form.control} name="email">
<Button type="submit">Submit</Button>
</form>
</Form>
)
return (
<Form {...form}>
<form className="space-y-8">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="" {...field} />
</FormControl>
<FormDescription>Your email address.</FormDescription>
<FormMessage /> *
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
);
// inside src/pages/index.tsx
export default function Home() {
return (
<div className="mx-auto max-w-xl">
<RegistrationForm />
</div>
);
}