Next.js App Setup
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.
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.
The application setup will take a moment. Once it's done, open the project in your preferred code editor.
Initializing shadcn
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:
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.
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:
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.
Adding Our First Components
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:
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:
Once that's done, it's time to start the application in development mode using pnpm:
Editing the Starter Code
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!
Building a User Registration 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.
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:
Connecting the Form to the 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.
Inferring the Form Structure
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.
Building Out JSX for the 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.
Checking 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:

Add the Remaining Fields
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!