DEV Community

Cover image for Stop Writing Messy Forms — Use This Modern Next.js Validation Stack
Marwan Zaky
Marwan Zaky

Posted on

Stop Writing Messy Forms — Use This Modern Next.js Validation Stack

Step by step on how to implement a modern Next.js form validation stack:

  • react-form-hook manages the state, submission, and error tracking
  • shadcn provides the pre-styled accessible components (e.g. Card, Input, Field) that tie into the hook.
  • zod defines the "shape" of your data and validation rules

Demo example: https://mamolio.vercel.app/signin

Demo code: https://github.com/marwanzaky/mern-ecommerce


1. Install Dependencies

Go to your existing Next.js app directory and install react-form-hook and zod

npm install react-hook-form zod @hookform/resolvers
Enter fullscreen mode Exit fullscreen mode

2. Install Shadcn Components

If you don't have shadcn installed already use this command

npx shadcn@latest init --preset b0 --template next
Enter fullscreen mode Exit fullscreen mode

Then use this to add input, field, and card components

npx shadcn@latest add input
npx shadcn@latest add field
npx shadcn@latest add card
Enter fullscreen mode Exit fullscreen mode

3. Login Form Page

Create new page app/signin/page.tsx

// app/signin/page.tsx
"use client";

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";

import {
    Card,
    CardContent,
    CardDescription,
    CardHeader,
    CardTitle,
} from "@shadcn/components/ui/card";
import {
    Field,
    FieldDescription,
    FieldError,
    FieldGroup,
    FieldLabel,
} from "@components/ui/field";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

const formSchema = z.object({
    email: z
        .email()
        .max(32, "Email is too short.")
        .nonempty("This field is required."),
    password: z.string().nonempty("This field is required."),
});

type FormValues = z.infer<typeof formSchema>;

export default function LoginForm() {
    const form = useForm<FormValues>({
        resolver: zodResolver(formSchema),
        mode: "onChange",
        defaultValues: {
            email: "",
            password: "",
        },
    });

    const onSubmit = async (data: FormValues) => {
        const { email, password } = data;
        console.log("email", email);
        console.log("password", password);
    };

    return (
        <Card className="mx-auto w-full max-w-sm">
            <CardHeader>
                <CardTitle>Login to your account</CardTitle>
                <CardDescription>
                    Enter your email below to login to your account
                </CardDescription>
            </CardHeader>
            <CardContent>
                <form onSubmit={form.handleSubmit(onSubmit)}>
                    <FieldGroup>
                        <Controller
                            name="email"
                            control={form.control}
                            render={({ field, fieldState }) => (
                                <Field data-invalid={fieldState.invalid}>
                                    <FieldLabel htmlFor="email">
                                        Email
                                    </FieldLabel>
                                    <Input
                                        {...field}
                                        id="email"
                                        aria-invalid={fieldState.invalid}
                                        placeholder="m@example.com"
                                        autoComplete="off"
                                    />
                                    {fieldState.invalid && (
                                        <FieldError
                                            errors={[fieldState.error]}
                                        />
                                    )}
                                </Field>
                            )}
                        />

                        <Controller
                            name="password"
                            control={form.control}
                            render={({ field, fieldState }) => (
                                <Field data-invalid={fieldState.invalid}>
                                    <div className="flex items-center">
                                        <FieldLabel htmlFor="password">
                                            Password
                                        </FieldLabel>

                                        <Link
                                            href="/forgot-password"
                                            className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
                                        >
                                            Forgot your password?
                                        </Link>
                                    </div>
                                    <Input
                                        {...field}
                                        id="password"
                                        type="password"
                                        aria-invalid={fieldState.invalid}
                                        autoComplete="off"
                                    />
                                    {fieldState.invalid && (
                                        <FieldError
                                            errors={[fieldState.error]}
                                        />
                                    )}
                                </Field>
                            )}
                        />

                        <Field>
                            <Button type="submit">Login</Button>
                            <Button type="button" variant="outline">
                                Login with Google
                            </Button>
                            <FieldDescription className="text-center">
                                Don&apos;t have an account?{" "}
                                <Link href="/signup">Sign up</Link>
                            </FieldDescription>
                        </Field>
                    </FieldGroup>
                </form>
            </CardContent>
        </Card>
    );
}
Enter fullscreen mode Exit fullscreen mode

4. Why this stack works well

  • react-hook-form → minimal re-renders, great performance
  • zod → schema = validation + types in one place
  • shadcn → accessible, composable UI components

You are all set to use form validation for anything really

More live examples

Feel free to review the code on GitHub

You may also want to learn How to Implement Google OAuth 2.0 in Next.js with NestJS

Top comments (0)