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.
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.
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
:
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.
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:
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
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:
// inside src/app/globals.css
body {
@apply dark;
}
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
:
// inside src/pages/index.tsx
export default function Home() {
return <div></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
.
import { z } from 'zod';
export const schema = z.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:
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'
}),
});
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:
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'
}),
});
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:
// inside of src/app/RegistrationForm.tsx
"use client";
import { useForm } from 'react-hook-form';
export const RegistrationForm = () => {
}
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:
export const RegistrationForm = () => {
const form = useForm({
defaultValues: {
first: '',
last: '',
email: '',
}
});
}
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
.
import { zodResolver } from '@hookform/resolvers/zod';
import { schema } from './registrationSchema';
We'll then use the template syntax after useForm
to specify the schema to the form:
export const RegistrationForm = () => {
const form = useForm<{
first: string;
last: string;
email: string;
}>({
resolver: zodResolver(schema),
defaultValues: {
first: "",
last: "",
email: "",
},
});
};
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
:
import { z } from 'zod';
type OurSchema = z.infer<typeof schema>;
Hovering over OurSchema
shows us the inferred types we expect:
// hover over OurSchema
type OurSchema = {
first: string;
last: string;
email: string;
}
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:
export const RegistrationForm = () => {
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: {
first: "",
last: "",
email: "",
}
});
};
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:
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
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 RegistrationForm
return (
<Form {...form}>
<form className="space-y-8">
</form>
</Form>
)
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:
// inside RegistrationForm
return (
<Form {...form}>
<form className="space-y-8">
<FormField control={form.control} name="email">
<Button type="submit">Submit</Button>
</form>
</Form>
)
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:
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>
);
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
:
// inside src/pages/index.tsx
export default function Home() {
return (
<div className="mx-auto max-w-xl">
<RegistrationForm />
</div>
);
}
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!