NextAuth Credentials — easy signup & login with email & password (Next.js 14 App router and Zod resolver)


NextAuth.js is a robust, open-source authentication solution tailor-made for Next.js applications, seamlessly blending with Next.js and Serverless environments. It accommodates many popular sign-in options, including email and passwordless sign-ins, making it a versatile choice for developers. Although it demands a bit more effort to configure compared to solutions like Clerk, it rewards users with unmatched control over data management and unparalleled flexibility in customization without incurring extra costs.
You can read this also on Medium here.
In addition to its comprehensive authentication capabilities, which are compatible with any backend system (e.g., Active Directory, LDAP), NextAuth.js is compatible with JSON Web Tokens and database sessions.
A notable feature that enhances its form-handling capabilities is the integration with Zod, a TypeScript-first schema declaration and validation library. By utilizing Zod alongside the Zod resolver with react-hook-form, developers can enforce strong typing and validation for form data seamlessly. This combination streamlines the development process and significantly improves data integrity and user experience by leveraging Zod's schema validation to catch errors early and ensure that only valid data is processed.
Let me walk you through the configuration of NextAuth.js with the modern Full Stack Next.js App (using app router, powered by TailwindCSS & shadcn).
See the repository here
INTRODUCTION
Assuming you have a Next.js project already initialized and pushed to GitHub, the following steps will guide you through setting up a PostgreSQL database directly on Vercel for your project.
1 — You click on Storage and then Create Database button.

2- You can then select Postgres Serverless SQL

3 — add a name for your database and click Create

4 — and then connect the newly created database to your project

5 — and then connect the newly created database to your project
Make sure you have installed the vercel CLI:
1npm i -g vercel
6 — then you link your project with the Postgres database on your environment (you will be asked 3 questions):
1vercel link
7 — now you want to clone all the environment variables created while setting up Postgres on Vercel
1vercel env pull .env.development.local
You can probably see that .env.development.local has been created with all the necessary credentials.
I suggest commenting on “ VERCEL=”1" ” as per the screenshot as it may force https while we are at our local host.

SETTING THE NECESSARY MODULES
I am using Shadcn to speed up the components' build.
1npm i bcrypt next-auth2npm i --save-dev @types/bcrypt3npm i @vercel/postgres4npx shadcn-ui@latest init5npx shadcn-ui@latest add form
SETTING UP OUR APP DIRECTORY AND THE LOGIC
Let’s prepare the register and the login page
1>app2 >login3 form.tsx4 page.tsx5 >register6 form.tsx7 page.tsx
and the API folder structure for NextAuth
1>app2 >api3 >auth4 >[...nextauth]5 route.ts
CLOSER LOOK AT NEXT AUTH DOCUMENTATION
The Credentials provider allows you to handle signing in with arbitrary credentials, such as a username and password, domain, or two-factor authentication or hardware device (e.g. Yubikey U2F/FIDO)
1 — Let’s prepare the route.ts within api/auth/[…nextauth] according to the documentation. I have removed the authorize logic for now.
1// app>api>auth>[...nextauth]>route.ts23import NextAuth from "next-auth/next";4import CredentialsProvider from "next-auth/providers/credentials";5import { sql } from "@vercel/postgres";6import { compare } from "bcrypt";78const handler = NextAuth({9 session: {10 strategy: "jwt",11 },1213 pages: {14 signIn: "/login",15 },1617 providers: [18 CredentialsProvider({19 // The name to display on the sign in form (e.g. 'Sign in with...')20 name: "Credentials",21 // The credentials is used to generate a suitable form on the sign in page.22 // You can specify whatever fields you are expecting to be submitted.23 // e.g. domain, username, password, 2FA token, etc.24 // You can pass any HTML attribute to the <input> tag through the object.25 credentials: {26 email: {},27 password: {},28 },29 async authorize(credentials, req) {30 return null;31 },32 }),33 ],34});3536export { handler as GET, handler as POST };
2 — also, let’s prepare the api/auth/register/route.ts to see if we are correctly passing the values from the Zod-validated form
1// app>api>auth>register>route.ts23import { NextResponse } from "next/server";45export async function POST(request: Request) {6 try {7 const { email, password } = await request.json();8 // YOU MAY WANT TO ADD SOME VALIDATION HERE910 console.log({ email, password });11 } catch (e) {12 console.log({ e });13 }1415 return NextResponse.json({ message: "success" });16}
3 — Let’s prepare the register frontend:
1import { getServerSession } from "next-auth";2import { redirect } from "next/navigation";34import FormPage from "./form";56export default async function RegisterPage() {7 const session = await getServerSession();89 if (session) {10 redirect("/");11 }1213 return (14 <section className="bg-black h-screen flex items-center justify-center">15 <div className="w-[600px]">16 <FormPage />17 </div>18 </section>19 );20}
4 — and the FormPage itself (using react-hook-form and zod validation)
1"use client";23import { zodResolver } from "@hookform/resolvers/zod";4import { useForm } from "react-hook-form";5import * as z from "zod";67import { Button } from "@/components/ui/button";8import {9 Form,10 FormControl,11 FormDescription,12 FormField,13 FormItem,14 FormLabel,15 FormMessage,16} from "@/components/ui/form";17import { Input } from "@/components/ui/input";18import { toast } from "@/components/ui/use-toast";1920const FormSchema = z.object({21 username: z.string().min(2, {22 message: "Username must be at least 2 characters.",23 }),24 password: z.string().min(6, {25 message: "Password must be at least 6 characters.",26 }),27});2829type FormData = z.infer<typeof FormSchema>;3031export default function FormPage() {32 const form = useForm({33 resolver: zodResolver(FormSchema),34 defaultValues: {35 username: "",36 password: "",37 },38 });3940 const onSubmit = async (data: FormData) => {41 console.log("Submitting form", data);4243 const { username: email, password } = data;4445 try {46 const response = await fetch("/api/auth/register", {47 method: "POST",48 headers: {49 "Content-Type": "application/json",50 },51 body: JSON.stringify({ email, password }),52 });53 if (!response.ok) {54 throw new Error("Network response was not ok");55 }56 // Process response here57 console.log("Registration Successful", response);58 toast({ title: "Registration Successful" });59 } catch (error: any) {60 console.error("Registration Failed:", error);61 toast({ title: "Registration Failed", description: error.message });62 }63 };6465 return (66 <Form {...form} className="w-2/3 space-y-6">67 <form onSubmit={form.handleSubmit(onSubmit)}>68 <FormField69 control={form.control}70 name="username"71 render={({ field }) => (72 <FormItem>73 <FormLabel>Username</FormLabel>74 <FormControl>75 <Input placeholder="Username" {...field} />76 </FormControl>77 <FormDescription>78 This is your public display name.79 </FormDescription>80 </FormItem>81 )}82 />83 <FormField84 control={form.control}85 name="password"86 render={({ field }) => (87 <FormItem>88 <FormLabel>Password</FormLabel>89 <FormControl>90 <Input placeholder="Password" {...field} type="password" />91 </FormControl>92 </FormItem>93 )}94 />95 <Button type="submit">Submit</Button>96 </form>97 </Form>98 );99}
The FormPage's interactivity forces it to be a client component, hence the “use client” directive above.
Please look closely into the onSubmit async function where we pass the form data (email, password) to /api/auth/register.
1const onSubmit = async (data: FormData) => {2 console.log("Submitting form", data);34 const { username: email, password } = data;56 try {7 const response = await fetch("/api/auth/register", {8 method: "POST",9 headers: {10 "Content-Type": "application/json",11 },12 body: JSON.stringify({ email, password }),13 });14 if (!response.ok) {15 throw new Error("Network response was not ok");16 }17 // Process response here18 console.log("Registration Successful", response);19 toast({ title: "Registration Successful" });20 } catch (error: any) {21 console.error("Registration Failed:", error);22 toast({ title: "Registration Failed", description: error.message });23 }24 };
So at this stage, when visiting our /register page we should see the following:

and, we should be able to see the inserted data in the console:

DATABASE PREPARATION
We need a users’ table in our database at this stage.

Now, let’s incorporate the registration logic, which includes password hashing, within api/auth/register/route.ts:
1import { NextResponse } from "next/server";2import { hash } from "bcrypt";3import { sql } from "@vercel/postgres";45export async function POST(request: Request) {6 try {7 const { email, password } = await request.json();8 // YOU MAY WANT TO ADD SOME VALIDATION HERE910 console.log({ email, password });1112 const hashedPassword = await hash(password, 10);1314 const response =15 await sql`INSERT INTO users (email, password) VALUES (${email}, ${hashedPassword})`;16 } catch (e) {17 console.log({ e });18 }1920 return NextResponse.json({ message: "success" });21}
now, after tackling the registration, we should see our registered customer in the Postgres db:

BIG MILESTONE — REGISTRATION TO POSTGRES DB FROM OUR NEXT.JS FRONTEND IS WORKING AS EXPECTED :)
LOGIN FUNCTIONALITY NOW
Here is my code for the login page:
1// >app>login>page.tsx23import { getServerSession } from "next-auth";4import { redirect } from "next/navigation";5import LoginForm from "./form";67export default async function LoginPage() {8 const session = await getServerSession();9 console.log({ session });1011 if (session) {12 redirect("/");13 }1415 return (16 <section className="bg-black h-screen flex items-center justify-center">17 <div className="w-[600px]">18 <LoginForm />;19 </div>20 </section>21 );22}
and, the LoginForm component:
1"use client";23import { signIn } from "next-auth/react";4import { useRouter } from "next/navigation";5import { zodResolver } from "@hookform/resolvers/zod";6import { useForm } from "react-hook-form";7import * as z from "zod";89import { Button } from "@/components/ui/button";10import {11 Form,12 FormControl,13 FormDescription,14 FormField,15 FormItem,16 FormLabel,17 FormMessage,18} from "@/components/ui/form";19import { Input } from "@/components/ui/input";20import { toast } from "@/components/ui/use-toast";2122const FormSchema = z.object({23 email: z.string().email({24 message: "Invalid email address.",25 }),26 password: z.string().min(6, {27 message: "Password must be at least 6 characters.",28 }),29});3031type FormData = z.infer<typeof FormSchema>;3233export default function LoginForm() {34 const router = useRouter();3536 const form = useForm({37 resolver: zodResolver(FormSchema),38 defaultValues: {39 email: "",40 password: "",41 },42 });4344 const onSubmit = async (data: FormData) => {45 console.log("Submitting form", data);4647 const { email, password } = data;4849 try {50 const response: any = await signIn("credentials", {51 email,52 password,53 redirect: false,54 });55 console.log({ response });56 if (!response?.error) {57 router.push("/");58 router.refresh();59 }6061 if (!response.ok) {62 throw new Error("Network response was not ok");63 }64 // Process response here65 console.log("Login Successful", response);66 toast({ title: "Login Successful" });67 } catch (error: any) {68 console.error("Login Failed:", error);69 toast({ title: "Login Failed", description: error.message });70 }71 };7273 return (74 <Form {...form} className="w-2/3 space-y-6">75 <form76 onSubmit={form.handleSubmit(onSubmit)}77 className="text-white p-4 md:p-16 border-[1.5px] rounded-lg border-gray-300 flex flex-col items-center justify-center gap-y-6"78 >79 <FormField80 control={form.control}81 name="email"82 render={({ field }) => (83 <FormItem>84 <FormLabel>Provide Email</FormLabel>85 <FormControl>86 <Input87 className="text-black"88 placeholder="Provide Email"89 {...field}90 type="text"91 />92 </FormControl>93 </FormItem>94 )}95 />96 <FormField97 control={form.control}98 name="password"99 render={({ field }) => (100 <FormItem>101 <FormLabel>Provide Password</FormLabel>102 <FormControl>103 <Input104 className="text-black"105 placeholder="Hasło"106 {...field}107 type="password"108 />109 </FormControl>110 </FormItem>111 )}112 />113 <Button114 type="submit"115 className="hover:scale-110 hover:bg-cyan-700"116 disabled={form.formState.isSubmitting}117 >118 {form.formState.isSubmitting ? "Opening...." : "Open Sesame!"}119 </Button>120 </form>121 </Form>122 );123}
This should accurately display the entered email and password in the console:

We can now proceed to implement the authorization logic, which involves retrieving the user from the database, dehashing their password, and comparing it with the credentials provided. For the purposes of this tutorial, I’ll be using plain SQL queries, although I acknowledge the various advantages and disadvantages associated with this approach.
1import NextAuth from "next-auth/next";2import CredentialsProvider from "next-auth/providers/credentials";3import { sql } from "@vercel/postgres";4import { compare } from "bcrypt";56const handler = NextAuth({7 session: {8 strategy: "jwt",9 },1011 pages: {12 signIn: "/login",13 },1415 providers: [16 CredentialsProvider({17 // The name to display on the sign in form (e.g. 'Sign in with...')18 name: "Credentials",19 credentials: {20 email: {},21 password: {},22 },23 async authorize(credentials, req) {24 const response = await sql`25 SELECT * FROM users WHERE email=${credentials?.email}26 `;27 const user = response.rows[0];2829 const passwordCorrect = await compare(30 credentials?.password || "",31 user.password32 );3334 if (passwordCorrect) {35 return {36 id: user.id,37 email: user.email,38 };39 }4041 console.log("credentials", credentials);42 return null;43 },44 }),45 ],46});4748export { handler as GET, handler as POST };
Additionally, ensure to include NEXTAUTH_URL and NEXTAUTH_SECRET in your .env file. You can generate a secure NEXTAUTH_SECRET value by executing openssl rand -base64 32 in your terminal.
1NEXTAUTH_URL="http://localhost:3000"2NEXTAUTH_SECRET=password
CONFIGURING RESTRICTED PAGES
To enforce access control on your pages, create a middleware.ts file in the root directory of your Next.js project:
1export { default } from "next-auth/middleware";23export const config = {4 // specify the route you want to protect5 matcher: ["/"],6};
With this setup, your application is now configured to restrict access to specified pages, working seamlessly with the JWT strategy and redirection policy defined in your [...nextauth].ts configuration.
Happy coding :)
Piotr

