This is the documentation for the process of building the project, which gives some insight behind certain choices and design decisions.
This also facilitates the onboarding process, which helps others be informed and caught up to speed.
Authentication is a core feature for nearly all applications, so this project provides a robust foundation for secure web applications.
-
Time Savings: It provides a ready-to-use foundation, significantly reducing the time needed to set up authentication from scratch for new projects.
-
Consistency: Ensures a consistent authentication setup across multiple projects, which can help maintain best practices and reduce errors.
-
Security: By using a well-tested starter template, you can ensure that common security vulnerabilities are addressed, providing a more secure authentication system.
-
Scalability: A starter template can be designed to handle various authentication methods (e.g., OAuth, email/password, passwordless), making it easier to scale and adapt to different project requirements.
-
Learning Tool: It serves as an excellent resource for developers to learn how to implement authentication in Next.js, especially for those new to the framework or authentication concepts.
-
Community Contribution: Sharing a starter template can help the developer community by providing a reliable starting point, fostering collaboration, and improving overall code quality.
-
Customization: It allows for easy customization and extension, enabling developers to tailor the authentication system to specific project needs without starting from scratch.
-
Best Practices: Embeds best practices for authentication, session management, and security, ensuring that new projects adhere to high standards from the beginning.
Let's get started with Next.js 14 - App Router.
npx create-next-app@latest
Now we answer the prompts that defines the set up of our project
What is your project named? my-app
√ Would you like to use TypeScript? ... No / [Yes]
√ Would you like to use ESLint? ... No / [Yes]
√ Would you like to use Tailwind CSS? ... No / [Yes]
√ Would you like to use `src/` directory? ... [No] / Yes
√ Would you like to use App Router? (recommended) ... No / [Yes]
√ Would you like to customize the default import alias (@/*)? ... [No] / Yes
chore: Initialize Next.js 14 app router project
The next step is to initialize shadcn/ui.
npx shadcn-ui@latest init
You will be asked a few questions to configure components.json
:
Need to install the following packages:
shadcn-ui@0.6.0
Ok to proceed? (y) y
Which style would you like to use? › Default
Which color would you like to use as base color? › Slate
Do you want to use CSS variables for colors? › no / [yes]
chore: Configure shadcn/ui for project
To ensure that the entire viewport height is utilized, inside globals.css
add the following code:
style: Use full viewport height for better layout
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
:root {
height: 100%;
}
Tailwind groups styles into layers because in CSS, the order of the rules in your stylesheet decides which declaration wins when two selectors have the same specificity:
.btn {
background: blue;
/* ... */
}
.bg-black {
background: black;
}
Here, both buttons will be black since .bg-black
comes after .btn
in the CSS:
<button class="btn bg-black">...</button>
<button class="bg-black btn">...</button>
To manage this, Tailwind organizes the styles it generates into three different "layers" — a concept popularized by ITCSS.
- The
base
layer is for things like reset rules or default styles applied to plain HTML elements. - The
components
layer is for class-based styles that you want to be able to override with utilities. - The
utilities
layer is for small, single-purpose classes that should always take precedence over any other styles.
Being explicit about this makes it easier to understand how your styles will interact with each other, and using the @layer
directive lets you control the final declaration order while still organizing your actual code in whatever way you like.
Add any of your own custom utility classes to Tailwind's utilities layer. This can be useful when there's a CSS feature you'd like to use in your project that Tailwind doesn't include utilities for out of the box.
Let's add a page background gradient utility class named page-bg-gradient
.
style: Add page-bg-gradient utility class
app\globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.page-bg-gradient {
@apply bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-sky-400 to-blue-800;
}
}
This leverages Tailwind's utility-first principles while allowing us to create reusable and maintainable custom classes.
Now use the custom utility class in the JSX:
refactor: Remove boilerplate code from Home page
- Simplified the Home component by removing unnecessary boilerplate code
- Added the page background gradient utility class
app\page.tsx
export default function Home() {
return (
<main className="page-bg-gradient">
Home
</main>
);
}
Let's make the first component AuthIntro.tsx
file inside /components
. It will return a heading and a paragraph.
import React from 'react';
export default function AuthIntro() {
const heading: string = "Auth 🛡️";
return (
<div>
<h1 className="text-6xl text-white font-semibold drop-shadow-md">
{heading}
</h1>
<p className="text-lg text-white">
An authentication service that shields your identity with the power of Auth.js
</p>
</div>
)
}
Here are a few improvements to consider:
- Semantic HTML: Use semantic HTML elements like
<header>
and<section>
to improve accessibility and SEO. - Container Styling: Add some styling to the container
<div>
to ensure proper spacing and alignment. - Props for Flexibility: Make the component more flexible by accepting
heading
anddescription
as props.
feat: Improve AuthIntro structure & flexibility
- Use semantic HTML elements
<header>
for improved accessibility and SEO - Introduce props (heading and description) to make the component more flexible
- Update default values for heading and description
import React from 'react';
interface AuthIntroProps {
heading?: string;
description?: string;
}
export default function AuthIntro({
heading = "Auth 🛡️",
description = "An authentication service that shields your identity with the power of Auth.js",
}: AuthIntroProps) {
return (
<header className="text-center text-white">
<h1 className="text-6xl font-semibold drop-shadow-md">
{heading}
</h1>
<p className="text-lg">
{description}
</p>
</header>
);
};
Now use AuthIntro
inside the Home
page.
feat: Add AuthIntro component to Home page
import AuthIntro from "@/components/AuthIntro";
export default function Home() {
return (
<section className="flex flex-col items-center justify-center min-h-screen page-bg-gradient">
<div className="space-y-6">
<AuthIntro />
</div>
</section>
);
}
refactor: Move AuthIntro to /components/auth
- Relocated AuthIntro component for better organization
- Updated import paths accordingly
Principles behind this move:
- Separation of Concerns: Grouping components by functionality
- Scalability: Easier to manage as the project grows
- Reusability: Components can be reused across the application
- Maintainability: Simplifies locating and updating components
- Collaboration: Helps new developers onboard quickly
- Consistency: Ensures a cohesive codebase
Let's install the shadcn/ui button component.
npx shadcn-ui@latest add button
Now create the SignInButton
component and use it in the home page.
import React from 'react';
import { Button } from '@/components/ui/button';
export default function SignInButton() {
return (
<Button variant="secondary" size="lg">
Sign In
</Button>
)
}
feat: Add SignInButton component to Home page
import AuthIntro from "@/components/AuthIntro";
import SignInButton from "@/components/SignInButton";
export default function Home() {
return (
<section className="flex flex-col items-center justify-center min-h-screen page-bg-gradient">
<div className="flex flex-col items-center space-y-6">
<AuthIntro />
<SignInButton />
</div>
</section>
);
}
refactor: Move SignInButton to /components/auth
- Relocated SignInButton component for better organization
- Updated import paths accordingly
Principles behind this move:
- Separation of Concerns: Grouping components by functionality
- Scalability: Easier to manage as the project grows
- Reusability: Components can be reused across the application
- Maintainability: Simplifies locating and updating components
- Collaboration: Helps new developers onboard quickly
- Consistency: Ensures a cohesive codebase
Made this change so that one can just move the /components/auth
folder and adapt it into a project with ease.
Now let's create the SignInButtonProps
interface that contains the mode
which could either be "modal"
or "redirect"
. And a boolean asChild
prop. When assigning the interface, set the mode
to redirect
by default. While here we also add the styles font-semibold cursor-pointer
.
feat: Define prop types for SignInButton
import React from 'react';
import { Button } from '@/components/ui/button';
interface SignInButtonProps {
asChild?: boolean;
mode?: "modal" | "redirect";
};
export default function SignInButton({
asChild,
mode = "redirect",
}: SignInButtonProps) {
return (
<Button
size="lg"
variant="secondary"
className='font-semibold cursor-pointer'
>
Sign In
</Button>
)
}
Then we need to mark the component as "use client"
, because Event handlers cannot be passed to Client Component props.
Next add a click handler function handleSignInClick
and assign it the Button
's onClick
prop. The handler will use useRouter
from next/navigation
to push the router
to the /auth/signin
path.
feat: Implement handleSignInClick in SignInButton
- Mark component as "use client"
- Use
useRouter
for navigation - Implement
handleSignInClick
function to navigate to /auth/signin - Assign
handleSignInClick
toonClick
"use client";
import React from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
interface SignInButtonProps {
asChild?: boolean;
mode?: "modal" | "redirect";
};
export default function SignInButton({
asChild,
mode = "redirect",
}: SignInButtonProps) {
const router = useRouter();
const handleSignInClick = () => {
router.push("/auth/signin");
}
return (
<Button
onClick={handleSignInClick}
size="lg"
variant="secondary"
className='font-semibold cursor-pointer'
>
Sign In
</Button>
)
}
Note: Using arrow functions over function declarations for event handlers and callbacks in React.
Here are some reasons why an arrow function is used instead of a function declaration:
-
Lexical
this
Binding: Arrow functions do not have their ownthis
context. They inheritthis
from the surrounding scope, which can be useful in React components to avoid issues withthis
binding. -
Conciseness: Arrow functions provide a more concise syntax, making the code shorter and often easier to read.
-
Consistency: Using arrow functions for event handlers and callbacks is a common practice in React, promoting consistency across your codebase.
-
Avoiding Rebinding: With arrow functions, you don't need to worry about rebinding
this
in methods or using.bind(this)
in the constructor, which can simplify your code.
Here's a comparison:
Arrow Function:
const handleSignInClick = () => {
console.log("Sign In Button was clicked!");
};
Function Declaration:
function handleSignInClick() {
console.log("Sign In Button was clicked!");
}
In this specific case, using an arrow function helps keep the code concise and avoids potential issues with this
binding, even though this
isn't directly used in the function.
In the return statement, let's return a different JSX element when the mode
is "modal"
.
feat: Add conditional rendering in SignInButton
- Implement conditional rendering based on
mode
prop - Add TODO comment for modal functionality
- Render signin redirect button by default
"use client";
import React from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
interface SignInButtonProps {
asChild?: boolean;
mode?: "modal" | "redirect";
};
export default function SignInButton({
asChild,
mode = "redirect",
}: SignInButtonProps) {
const router = useRouter();
const handleSignInClick = () => {
router.push("/auth/signin");
}
return (
<>
{mode === "modal" ? (
/* TODO: Implement modal functionality */
<div>Modal</div>
) : (
/* Render a sign-in redirect button */
<Button
onClick={handleSignInClick}
size="lg"
variant="secondary"
className='font-semibold cursor-pointer'
>
Sign In
</Button>
)}
</>
);
}
feat: Implement initial design of SignInButton
feat: Create SignInPage component
- Add SignInPage component in
app/auth/signin/page.tsx
- Initial implementation with basic structure
app\auth\signin\page.tsx
import React from 'react';
export default function SignInPage() {
return (
<div>SignInPage</div>
)
}
refactor: Rename login to signin in pages & routes
- Update route from
/auth/login
to/auth/signin
- Rename LoginPage component to SignInPage
- Adjust all references to the new route
This refactor enhances consistency across the application, aligns with common user expectations for authentication flows, and ensures a cohesive user experience.
feat: Create AuthLayout component
app\auth\layout.tsx
import React from 'react';
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="auth-layout h-full flex flex-col items-center justify-center page-bg-gradient">
<main className="auth-content h-full flex items-center justify-center">
{children}
</main>
</div>
);
}
Here we use the main
tag wrapped by a div
element. This is so we can later extend this layout to include header
and footer
section when needed.
Create a react functional component SignInForm
in /components/auth
.
components\auth\SignInForm.tsx
import React from 'react';
export default function SignInForm() {
return (
<div>SignInForm</div>
)
}
Then use the SignInForm
inside SignInPage
.
feat: Use SignInForm component in SignInPage
app\auth\signin\page.tsx
import React from 'react';
import SignInForm from '@/components/auth/SignInForm';
export default function SignInPage() {
return (
<SignInForm />
);
}
Install shadcn/ui card.
npx shadcn-ui@latest add card
Then create create component /components/auth/CardWrapper.tsx
. Then create the prop interface and assign it to the component.
feat: Define prop types for CardWrapper
"use client";
import React from 'react';
interface CardWrapperProps {
children: React.ReactNode;
backButtonHref: string;
backButtonLabel: string;
headerLabel: string;
showSocialSignIn?: boolean;
};
export default function CardWrapper({
children,
backButtonHref,
backButtonLabel,
headerLabel,
showSocialSignIn,
}: CardWrapperProps) {
return (
<div>CardWrapper</div>
)
}
Return a Card
as the output of CardWrapper
feat: Implement Card component in CardWrapper
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
export default function CardWrapper({
// ...props
}: CardWrapperProps) {
return (
<Card className='w-96 shadow-md'>
{children}
</Card>
)
}
Then return the CardWrapper
component as the output of the SignInForm
.
feat: Use CardWrapper component in SignInForm
import React from 'react';
import CardWrapper from '@/components/auth/CardWrapper';
export default function SignInForm() {
return (
<CardWrapper>
SignInForm
</CardWrapper>
)
}
Now let's pass the prop values to CardWrapper
to implement the SignInForm
.
feat: Implement SignInForm using CardWrapper
export default function SignInForm() {
return (
<CardWrapper
backButtonHref="/auth/signup"
backButtonLabel="Don't have an account?"
headerLabel="Welcome back"
showSocialSignIn={true}
>
SignInForm
</CardWrapper>
);
}
Now before we improve the CardWrapper
component, we need to create a reusable component to display the header. Create the AuthHeader
component, which will have a prop label
and optional prop heading
.
feat: Define prop types for AuthHeader component
import React from 'react';
interface AuthHeaderProps {
heading?: string;
label: string;
};
export default function AuthHeader({
heading = "Auth 🛡️",
label,
}: AuthHeaderProps) {
return (
<div>
AuthHeader
</div>
)
}
Then update the output with the heading
and label
.
feat: Implement AuthHeader with dynamic props
This commit adds the AuthHeader component, which accepts two props:
heading
(optional): The main heading for the authentication section.label
: The label or description for the authentication content.
import React from 'react';
interface AuthHeaderProps {
heading?: string;
label: string;
};
export default function AuthHeader({
heading = "Auth 🛡️",
label,
}: AuthHeaderProps) {
return (
<div className='w-full flex flex-col items-center justify-center gap-y-4'>
<h1 className='text-3xl font-semibold'>
{heading}
</h1>
<p className='text-sm text-muted-foreground'>
{label}
</p>
</div>
)
}
Now we can use the AuthHeader
inside the CardWrapper
's CardHeader
. While here we can also wrap the children
by the CardContent
.
feat: Use AuthHeader component in CardWrapper
import AuthHeader from '@/components/auth/AuthHeader';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
interface CardWrapperProps {
children: React.ReactNode;
backButtonHref: string;
backButtonLabel: string;
headerLabel: string;
showSocialSignIn?: boolean;
};
export default function CardWrapper({
children,
backButtonHref,
backButtonLabel,
headerLabel,
showSocialSignIn,
}: CardWrapperProps) {
return (
<Card className='w-96 shadow-md'>
<CardHeader>
<AuthHeader label={headerLabel} />
</CardHeader>
<CardContent>
{children}
</CardContent>
</Card>
)
}
feat: Create SocialSignIn component
import React from 'react';
export default function SocialSignIn() {
return (
<div className='w-full flex items-center gap-x-2'>
SocialSignIn
</div>
);
}
Next we need the SVG assets to display social sign in, OAuth2 for Github and Google.
feat: Add SVG assets for auth in /public/img/auth
This commit adds the necessary SVG files for authentication-related components. The SVGs are located in the /public/img/auth folder.
Let's get a few icons we need for the social login. You can install react-icons
package directly or manually add the specific icions from a smaller icon library using inline SVG icons. This way we can keep the bundle size smaller and avoid unecessary dependencies.
The icons to get are FaGithub
and FcGoogle
from react-icons. To add the icon files locally, inside the public
folder, create the /img/auth
folder. Then within create the name of the icon, e.g., github.svg
and inside input the svg code. We can find this on the website and inspect element on the icon you want and edit the SVG.
You can also use other icon libraries such as Flaticon, Iconoir or Icons8.
Now to use the SVG assets we can either use Image
from next/image
or Import SVGs as React components.
-
Embed SVGs using JSX syntax in a React component:
- You can directly import an SVG file and use it as a React component. For example:
import { ReactComponent as MyIcon } from '../path/to/MyIcon.svg'; // Usage: <MyIcon />
- Make sure your SVG files are located in a folder (e.g.,
public/img
) accessible to Next.js.
- You can directly import an SVG file and use it as a React component. For example:
-
Load SVGs using the
next/image
component:- The
next/image
component optimizes image loading, including SVGs. - Place your SVGs in the
public
folder. - Use the
next/image
component like this:import Image from 'next/image'; // Usage: <Image src="/img/MyIcon.svg" alt="My Icon" width={100} height={100} />
- The
docs: Explore SVG asset handling in Next.js
Let's explore the benefits of using both methods for handling SVGs in a Next.js application:
-
Using
next/image
:- Optimization: The
next/image
component is primarily designed for raster images, but it can also handle SVGs. It provides benefits like lazy loading, automatic image optimization, and responsive image loading. - Lazy Loading: With
next/image
, SVGs are loaded lazily, improving page load performance by only fetching the image when it's needed. - Automatic Optimization:
next/image
optimizes images based on the device and screen size, reducing the overall bundle size. - Accessibility: It ensures proper accessibility attributes for images.
- Consistency: If your project already uses
next/image
for other images, using it for SVGs maintains consistency.
- Optimization: The
-
Importing SVGs as React Components:
- Resolution Independence: SVGs are resolution-independent, meaning they can be scaled up or down without losing quality. This is useful for responsive designs.
- Direct Usage: You can import an SVG file as a React component using the
ReactComponent
syntax and render it directly in JSX. No need for additional conversion or data URLs. - Customization: As React components, SVGs can be easily customized with props or CSS.
- SEO-Friendly: Search engines can index and crawl SVGs, making them SEO-friendly.
- Animation and Interaction: SVGs can be animated and manipulated using CSS and JavaScript.
In summary, if you need lazy loading and automatic optimization, consider using next/image
. If you prefer direct customization and resolution independence, importing SVGs as React components is a great choice. Choose the method that best fits your project's requirements.
We can create the SVG icon by converting it into a react component. Then we can use the SVG component like this:
import React from 'react';
import GithubIcon from '@/public/img/auth/GitHubIcon';
import GoogleIcon from '@/public/img/auth/GoogleIcon';
export default function SocialSignIn() {
return (
<div className='w-full flex items-center gap-x-2'>
<GithubIcon />
<GoogleIcon />
</div>
);
}
Or we can use next/image
and use the svg
file directly.
Now for the output of SocialSignIn
return a Button
that wraps around an SVG asset.
feat: Add Google social sign-in button
import React from 'react';
import GoogleIcon from '@/public/img/auth/GoogleIcon';
import { Button } from '@/components/ui/button';
export default function SocialSignIn() {
return (
<div className='w-full flex items-center gap-x-2'>
<Button
size='lg'
className='w-full'
variant='outline'
>
<GoogleIcon className='h-5 w-5'/>
</Button>
</div>
);
}
Here is an alternative way using Image
component:
import Image from 'next/image';
import { Button } from '@/components/ui/button';
export default function SocialSignIn() {
return (
<div className='w-full flex items-center gap-x-2'>
<Button
size='lg'
className='w-full'
variant='outline'
>
<Image
src='/img/auth/google2.svg'
width={20}
height={20}
alt='Google Icon to sign in with Google'
/>
</Button>
</div>
);
}
Now here is an example SVG converted to JSX component to be used as an icon.
feat: Create GoogleColoredIcon SVG component
Create a new React component named GoogleColoredIcon that renders an SVG with the specified path data. Pass any additional props to the SVG element. SVG file comes from react-icons.
Source: https://github.com/react-icons/react-icons
public\img\auth\GoogleColoredIcon.tsx
import React from "react";
const GoogleColoredIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
x="0"
y="0"
fill="currentColor"
stroke="currentColor"
strokeWidth="0"
viewBox="0 0 48 48"
{...props} // Pass any additional props to the SVG
>
{/* path data */}
<path
fill="#FFC107"
d="..."
stroke="none"
></path>
<path
fill="#FF3D00"
d="..."
stroke="none"
></path>
<path
fill="#4CAF50"
d="..."
stroke="none"
></path>
<path
fill="#1976D2"
d="..."
stroke="none"
></path>
</svg>
);
export default GoogleColoredIcon;
Now use GoogleColoredIcon
in the SocialSignIn
.
feat: Update Google icon in SocialSignIn component
import React from 'react';
import GoogleColoredIcon from '@/public/img/auth/GoogleColoredIcon';
import { Button } from '@/components/ui/button';
export default function SocialSignIn() {
return (
<div className='w-full flex items-center gap-x-2'>
<Button
size='lg'
className='w-full'
variant='outline'
>
<GoogleColoredIcon className='h-5 w-5' />
</Button>
</div>
);
}
feat: Add GitHub social sign-in button
import React from 'react';
import GitHubIcon from '@/public/img/auth/GitHubIcon';
import GoogleColoredIcon from '@/public/img/auth/GoogleColoredIcon';
import { Button } from '@/components/ui/button';
export default function SocialSignIn() {
return (
<div className='w-full flex items-center gap-x-2'>
<Button
size='lg'
className='w-full'
variant='outline'
>
<GoogleColoredIcon className='h-5 w-5' />
</Button>
<Button
size='lg'
className='w-full'
variant='outline'
>
<GitHubIcon className='h-5 w-5' />
</Button>
</div>
);
}
feat: Add SocialSignIn in CardWrapper component
Conditionally render the SocialSignIn component within the CardWrapper. This component handles social sign-in functionality.
import SocialSignIn from '@/components/auth/SocialSignIn';
export default function CardWrapper({
children,
backButtonHref,
backButtonLabel,
headerLabel,
showSocialSignIn = true,
}: CardWrapperProps) {
return (
<Card className='w-96 shadow-md'>
<CardHeader>
<AuthHeader label={headerLabel} />
</CardHeader>
<CardContent>
{children}
</CardContent>
{showSocialSignInSignIn && (
<CardFooter>
<SocialSignIn />
</CardFooter>
)}
</Card>
)
}
Create the BackButton
component.
feat: Define prop types for BackButton component
import React from 'react';
import { Button } from '@/components/ui/button';
interface BackButtonProps {
href: string;
label: string;
};
export default function BackButton({
href,
label,
}: BackButtonProps) {
return (
<Button>BackButton</Button>
)
}
We want to put a Link
inside, but a Button
and a Link
serves different purposes! This will come across a difference of expectations for users and accessibility issues. Instead we have two ways to display a Link
with Button
-like styles, as we want here. According to the Button | shadcn/ui docs:
- We can use the
buttonVariants
helper to create a link that looks like a button.tsx import { buttonVariants } from "@/components/ui/button" <Link className={buttonVariants({ variant: "outline" })}>Click here</Link>
- Alternatively, you can set the
asChild
parameter and nest the link component. ```tsx Login
</Button>
```
For clarity, I will use the the buttonVariants
to make it clear that we are simply using the styles of the Button
.
refactor: Update BackButton component to use Link
This commit refactors the BackButton component to utilize the Next.js Link component for navigation. The 'href' prop specifies the link destination, and the 'label' prop sets the button text. The styling remains consistent with shadcn/ui.
import React from 'react';
import Link from 'next/link';
import { buttonVariants } from "@/components/ui/button"
import { cn } from '@/lib/utils';
interface BackButtonProps {
href: string;
label: string;
};
export default function BackButton({
href,
label,
}: BackButtonProps) {
return (
<Link
href={href}
className={cn(
'font-normal w-full text-primary underline-offset-4 hover:underline',
buttonVariants({ size: "sm" }),
buttonVariants({ variant: "destructive" }),
)}
>
{label}
</Link>
);
}
Now use BackButton
in CardWrapper
.
feat: Add BackButton component in CardWrapper
import BackButton from '@/components/auth/BackButton';
export default function CardWrapper({
children,
backButtonHref,
backButtonLabel,
headerLabel,
showSocialSignIn,
}: CardWrapperProps) {
return (
<Card className='w-96 shadow-md'>
<CardHeader>
<AuthHeader label={headerLabel} />
</CardHeader>
<CardContent>
{children}
</CardContent>
{showSocialSignInSignIn && (
<CardFooter>
<SocialSignIn />
</CardFooter>
)}
<CardFooter>
<BackButton
href={backButtonHref}
label={backButtonLabel}
/>
</CardFooter>
</Card>
)
}
npx shadcn-ui@latest add input
Time to build the form with React Hook Form and Zod.
npx shadcn-ui@latest add form
Let's check out what the command installed:
feat: Add form dependencies (react-hook-form, zod)
Dependencies:
- @hookform/resolvers@3.9.0
- @radix-ui/react-label@2.1.0
- react-hook-form@7.52.1
- zod@3.23.8
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-label": "^2.1.0",
// ...
"react-hook-form": "^7.52.1",
"zod": "^3.23.8"
},
-
Create a Form Schema:
- Define the shape of your form using a Zod schema. This schema will specify the expected structure of your form data.
- Example:
import { z } from "zod"; const formSchema = z.object({ username: z.string().min(2).max(50), email: z.string().email(), // Add other form fields here });
-
Define a Form:
- Use the
useForm
hook from react-hook-form to create a form instance. - Set up form validation, default values, and other configuration options.
- Example:
"use client" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form"; import { z } from "zod" const formSchema = z.object({ username: z.string().min(2).max(50), email: z.string().email(), // Add other form fields here }); function MyForm() { const form = useForm<z.infer<typeof formSchema>>({ resolver: zodResolver(formSchema), defaultValues: { username: "", email: "", // Set other default values }, }); // Handle form submission, field validation, etc. // ... }
- Use the
-
Build the Form:
- Use the
<Form>
components (provided by your UI library) to build your form. - Include form fields, labels, error messages, and any other necessary components.
- Example:
import { Form, FormField, FormSubmitButton } from "@radix-ui/react-form"; function MyForm() { // ... return ( <Form onSubmit={form.handleSubmit(onSubmit)}> <FormField label="Username" name="username" ref={form.register} /> <FormField label="Email" name="email" ref={form.register} /> {/* Add other form fields */} <FormSubmitButton>Submit</FormSubmitButton> </Form> ); }
- Use the
Here's the example from the docs.
- Create a form schema
Define the shape of your form using a Zod schema. You can read more about using Zod in the Zod documentation.
"use client"
import { z } from "zod"
const formSchema = z.object({
username: z.string().min(2).max(50),
})
- Define a form
Use the useForm
hook from react-hook-form
to create a form.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
})
export function ProfileForm() {
// 1. Define your form.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: "",
},
})
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
console.log(values)
}
}
Since FormField
is using a controlled component, you need to provide a default value for the field. See the React Hook Form docs to learn more about controlled components.
- Build your form
We can now use the <Form />
components to build our form.
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
const formSchema = z.object({
username: z.string().min(2, {
message: "Username must be at least 2 characters.",
}),
})
export function ProfileForm() {
// ...
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
- Done
That's it. You now have a fully accessible form that is type-safe with client-side validation.
Create a global schemas
folder, with a index.ts
which will contain our schemas. This will serve as a centralized location for form validation and other schemas.
- Create a form schema
The LoginSchema
will have an email
and a password
.
feat: Define SignIn schema using zod
schemas\index.ts
import { z } from "zod"
export const SignInSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
- Define a form
Use the useForm
hook from react-hook-form
to create a form.
Inside the SignInForm
component, let's import what we need and define the form.
feat: Define the sign-in form with useForm hook
- Mark as client component
- Import zodResolver, useForm, z, and SignInSchema
- Implement the sign-in form logic
components\auth\SignInForm.tsx
"use client";
import React from 'react';
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { SignInSchema } from '@/schemas';
import CardWrapper from '@/components/auth/CardWrapper';
export default function SignInForm() {
// 1. Define the sign-in form.
const form = useForm<z.infer<typeof SignInSchema>>({
resolver: zodResolver(SignInSchema),
defaultValues: {
email: "",
password: "",
},
});
return (
<CardWrapper
backButtonHref="/auth/signup"
backButtonLabel="Don't have an account?"
headerLabel="Welcome back"
showSocialSignIn={true}
>
SignInForm
</CardWrapper>
);
}
After defining the sign-in form, define the submit handler which will log the values.
feat: Define the sign-in submit handler
import { SignInSchema } from '@/schemas';
export default function SignInForm() {
// 1. Define the sign-in form.
const form = useForm<z.infer<typeof SignInSchema>>({
resolver: zodResolver(SignInSchema),
defaultValues: {
email: "",
password: "",
},
});
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof SignInSchema>) {
// Do something with the form values.
// This will be type-safe and validated.
console.log(values)
}
// ...
Then we can now build the form in the output by using <Form />
components. Inside the CardWrapper
add the Form
and a native form
element within. The Form
component will spread out the values contained within the form made from the useForm
hook from react-hook-form
. The form
element will have the onSubmit
set to the form.handleSubmit(onSubmit)
. Finally, render an Input
and Button
component within the form
element.
feat: Add form components for SignInForm
feat(auth): Create initial layout for SignInForm
feat(auth): Handle form submission in SignInForm
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
export default function SignInForm() {
const form = useForm<z.infer<typeof SignInSchema>>({
resolver: zodResolver(SignInSchema),
defaultValues: {
email: "",
password: "",
},
});
function onSubmit(values: z.infer<typeof SignInSchema>) {
console.log(values)
}
return (
<CardWrapper
backButtonHref="/auth/signup"
backButtonLabel="Don't have an account?"
headerLabel="Welcome back"
showSocialSignIn={true}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<Input />
<Button type="submit">Submit</Button>
</form>
</Form>
</CardWrapper>
);
}
Before adding the input fields let's create a div
container for them with the styles space-y-4
. Then right below the input field container div
we can add the submit button.
feat: Implement submit button in SignInForm
feat(auth): Add sign-in submit button
// ...
export default function SignInForm() {
// ...
return (
<CardWrapper>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className='space-y-4'>
{ /* Input fields */ }
</div>
<Button type="submit" className='w-full bg-sky-500'>
Sign In
</Button>
</form>
</Form>
</CardWrapper>
);
}
Now we need to add our inputs: email and password.
feat(auth): Create email input field in SignInForm
// ...
export default function SignInForm() {
// ...
return (
<CardWrapper
backButtonHref="/auth/signup"
backButtonLabel="Don't have an account?"
headerLabel="Welcome back"
showSocialSignIn={true}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className='space-y-4'>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type='email' placeholder="Enter your email address" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</CardWrapper>
);
}
feat: Add validation error handling in SignInForm
We can now test the email input field. Notice that when it validates it renders a <FormMessage />
which displays the text "invalid email" below the FormField
. We can change the validation error message through the zod schema by adding an object containing the message
inside the email()
.
feat: Add email validation error message in schema
schemas\index.ts
import { z } from "zod"
export const SignInSchema = z.object({
email: z.string().email({
message: "Please enter a valid email address."
}),
password: z.string().min(1),
});
Let's also add the validation error message for the password as well, we can add it to the min()
.
feat: Add password validation error message
schemas\index.ts
import { z } from "zod"
export const SignInSchema = z.object({
email: z.string().email({
message: "Please enter a valid email address."
}),
password: z.string().min(1, {
message: "Password must be at least 14 characters long."
}),
});
docs: Add robust password requirements
Note: I've chosen a minimum password length of 14 characters, prioritizing user security and safety. It may seem user-unfriendly but is done with an abundance of caution.
While here let's also strengthen the password requirements. According to the NIST: National Institute of Standards and Technology, password length has been found to be a primary factor in characterizing password strength.
To strengthen the security of your online information, ensure your passwords are a random mix of at least 14 to 16 characters.
Password Length | Time to Crack |
---|---|
14-16 characters | centuries |
11-13 | months to years |
8-10 | hours to days |
5-7 | seconds to minutes |
Password requirements:
- 14-16 characters long
- Contain at least one character from the four character sets:
- Numerical characters such as 12345
- Lowercase characters such as abcde
- Uppercase characters such as ABCDE
- Special characters such as !$%&?
To add uppercase and lowercase letter requirements, as well as a special character requirement to the SignInSchema
, refine the validation for the password
field. Here's the updated schema:
feat(SignInSchema): Strengthen password validation
- Increase minimum length to 14 characters
- Set maximum length to 32 characters
- Enforce requirements for at least one of each: an uppercase letter, a lowercase letter, a number, and a special character.
- Ensure password is not empty
import { z } from 'zod';
export const SignInSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z
.string()
.min(14, 'Password must be at least 14 characters long')
.max(32, 'Password must be a maximum of 32 characters')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+={[}]|:;\"'<,>.])[A-Za-z\d!@#$%^&*()_+={[}]|:;\"'<,>.]{14,}$/)
.refine((value) => value.length > 0, {
message: 'Password is required',
}),
});
In the updated schema:
- The
.regex(...)
method enforces the requirements for at least one uppercase letter, one lowercase letter, one number, and one special character. - The
.refine(...)
method ensures that the password is not empty.
-
Minimum Length: Set a minimum length of 14 characters for the password, which is a good practice to enhance security.
-
Maximum Length: Capped the maximum length at 32 characters, preventing excessively long passwords.
-
Regex Pattern:
- Regex pattern ensures that the password contains at least one lowercase letter (
(?=.*[a-z]
)), one uppercase letter ((?=.*[A-Z]
)), one digit ((?=.*\d)
), and one special character ((?=.*[!@#$%^&*()_+={[}]|:;\"'<,>.])
). - It allows any combination of these characters, as long as the total length is within the specified range.
- Regex pattern ensures that the password contains at least one lowercase letter (
-
Refinement:
- Added a refinement to ensure that the password length is greater than zero (
value.length > 0
), which is essential for a required field.
- Added a refinement to ensure that the password length is greater than zero (
Need to add an error message to clearly communicate to the users the password requirements.
feat: Improve password requirement communication
Added clear instructions to the error message in the SignInSchema
regex pattern, ensuring users understand the required criteria for their password.
import { z } from 'zod';
export const SignInSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z
.string()
.min(14, 'Password must be at least 14 characters long')
.max(32, 'Password must be a maximum of 32 characters')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+={[}]|:;\"'<,>.])[A-Za-z\d!@#$%^&*()_+={[}]|:;\"'<,>.]{14,}$/, {
message: 'Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character.',
})
.refine((value) => value.length > 0, {
message: 'Password is required',
}),
});
Now add the input form field for the password.
feat(auth): Add password input field in SignInForm
// ...
export default function SignInForm() {
// ...
return (
<CardWrapper
backButtonHref="/auth/signup"
backButtonLabel="Don't have an account?"
headerLabel="Welcome back"
showSocialSignIn={true}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className='space-y-4'>
<FormField
{ /* email input field... */ }
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type='password' placeholder="**************" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</CardWrapper>
);
}
Now I want to display feedback messages to the user in the sign-in form. The FormError
and FormSuccess
components will receieve a message prop (feedback message) and render it appropriately with the relevant styles.
Create these components in a new folder /components/form
.
The FormError
will contain the message
prop, which will be used to conditionally render the output.
feat: Define prop types for FormError component
components\form\FormError.tsx
import React from 'react';
interface FormErrorProps {
message?: string;
}
export default function FormError({
message,
}: FormErrorProps) {
return (
<div>FormError</div>
)
}
Now render the FormError
above the submit button in the SignInForm
.
feat: Render FormError component in SignInForm
import FormError from '@/components/form/FormError';
// ...
export default function SignInForm() {
// ...
return (
<CardWrapper
backButtonHref="/auth/signup"
backButtonLabel="Don't have an account?"
headerLabel="Welcome back"
showSocialSignIn={true}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className='space-y-4'>
<FormField
// email form field
/>
<FormField
// password form field
/>
</div>
<FormError />
<Button type="submit" className='w-full bg-sky-500'>
Sign In
</Button>
</form>
</Form>
</CardWrapper>
);
}
Now back in the FormError
component, render the output if the message
is truthy otherwise render null.
feat: Conditionally render FormError component
- Added conditional rendering logic to display error messages in the FormError component.
- Incorporated the ShieldAlert icon from the 'lucide-react' library
- Styled the error message container for better visibility and better convey its purpose
import { ShieldAlert } from 'lucide-react';
import React from 'react';
interface FormErrorProps {
message?: string;
}
export default function FormError({
message,
}: FormErrorProps) {
return message ? (
<div className="flex items-center p-3 gap-x-2 bg-destructive/15 text-destructive text-sm rounded-md">
<ShieldAlert className='h-4 w-4' />
<p>{message}</p>
</div>
) : null;
}
Using the same logic as the FormError
component, the only changes are the icons and colors (from destructive to emerald-500).
feat: Conditionally render FormSuccess component
- Added conditional rendering logic to display success messages in the FormSuccess component.
- Incorporated the ShieldCheck icon from the 'lucide-react' library
- Styled the message container for better visibility and clearly convey its purpose
import { ShieldCheck } from 'lucide-react';
import React from 'react';
interface FormSuccessProps {
message?: string;
}
export default function FormSuccess({
message,
}: FormSuccessProps) {
return message ? (
<div className="flex items-center p-3 gap-x-2 bg-emerald-500/15 text-emerald-500 text-sm rounded-md">
<ShieldCheck className='h-4 w-4' />
<p>{message}</p>
</div>
) : null;
}
feat: Render FormSuccess component in SignInForm
import FormSuccess from '@/components/form/FormSuccess';
// ...
export default function SignInForm() {
// ...
return (
<CardWrapper
backButtonHref="/auth/signup"
backButtonLabel="Don't have an account?"
headerLabel="Welcome back"
showSocialSignIn={true}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className='space-y-4'>
<FormField
// email form field
/>
<FormField
// password form field
/>
</div>
<FormError />
<FormSuccess />
<Button type="submit" className='w-full bg-sky-500'>
Sign In
</Button>
</form>
</Form>
</CardWrapper>
);
}
Now we can view the FormError
and FormSuccess
components by passing in a message
prop.
<FormError message='Email already taken!'/>
<FormSuccess message='Email sent!'/>
Server Actions are asynchronous functions that are executed on the server. They can be used in Server and Client Components to handle form submissions and data mutations in Next.js applications.
Create an /actions
folder at the root of the project with a file named signIn.ts
.
Inside has a function signIn
that takes in values
parameter and console.log(values)
. Let's import what z
and SignInSchema
to be ensure type safety and validation values
.
feat(signIn): Add type safety and validation
- Validate input using zod schema
- Ensure proper type inference for 'values'
actions\signIn.ts
"use server";
import { z } from "zod";
import { SignInSchema } from "@/schemas";
export default function signIn(values: z.infer<typeof SignInSchema>) {
console.log(values);
}
Now inside our SignInForm
we can import the server action and call it within the submit handler.
feat: Execute signIn server action in SignInForm
components\auth\SignInForm.tsx
import signIn from '@/actions/signIn';
export default function SignInForm() {
// 1. Define the sign-in form.
const form = useForm<z.infer<typeof SignInSchema>>({
resolver: zodResolver(SignInSchema),
defaultValues: {
email: "",
password: "",
},
});
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof SignInSchema>) {
// Do something with the form values.
// This will be type-safe and validated.
console.log(values);
// Execute the user sign-in server action
signIn(values);
}
On submit button press we should be able to see the values logged on the server (inside the terminal).
Note: An alternative to server action is through API routes. Here is how it would look like in the submit handler:
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof SignInSchema>) {
console.log(values);
axios.post("/some/api/route", values)
.then()
.catch();
}
useTransition
is a React Hook that lets you update the state without blocking the UI.
const [isPending, startTransition] = useTransition()
The useTransition
hook in React aims to address the issue of UI responsiveness during asynchronous operations, such as data fetching or form submissions. It allows you to create smooth transitions between different UI states, like showing a loading spinner while waiting for data or displaying a success message after form submission.
Here's how it works:
-
Smooth Transitions: When you use
useTransition
, React will delay the rendering of the new UI state until the transition is complete. This prevents abrupt changes and provides a smoother experience for users. -
Graceful Loading States: You can use
useTransition
to handle loading states elegantly. For example, when fetching data from an API, you can show a loading spinner while transitioning from the current UI to the loading state. -
Optimized Rendering: By delaying the rendering of new UI elements,
useTransition
helps avoid unnecessary re-renders. This optimization can improve performance, especially when dealing with complex components.
Note that useTransition
is part of React's Concurrent Mode, which is still experimental. But it's exciting to see how it evolves and becomes more widely adopted.
Now let's wrap the server action call inside the submit handler with a useTransition
.
feat: Add useTransition hook for form submission
- Use useTransition to handle asynchronous form submission
- Execute the user sign-in server action within the transition
import React, { useTransition } from 'react';
export default function SignInForm() {
const [isPending, startTransition] = useTransition();
function onSubmit(values: z.infer<typeof SignInSchema>) {
console.log(values);
startTransition(() => {
signIn(values);
});
}
Use the isPending
to disable the components (inputs). While here, also add aria-label
to the inputs to improve accessibility.
feat: Add aria-labels on inputs for accessibility
feat: Disable inputs and button while form pending
export default function SignInForm() {
const [isPending, startTransition] = useTransition();
// 1. Define the sign-in form.
const form = useForm<z.infer<typeof SignInSchema>>({
resolver: zodResolver(SignInSchema),
defaultValues: {
email: "",
password: "",
},
});
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof SignInSchema>) {
console.log(values);
startTransition(() => {
signIn(values);
});
}
return (
<CardWrapper
backButtonHref="/auth/signup"
backButtonLabel="Don't have an account?"
headerLabel="Welcome back"
showSocialSignIn={true}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className='space-y-4'>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder="Enter your email address"
aria-label="Email address"
disabled={isPending}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder="**************"
aria-label="Password"
disabled={isPending}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormError />
<FormSuccess />
<Button
type="submit"
disabled={isPending}
className='w-full bg-sky-500'
>
Sign In
</Button>
</form>
</Form>
</CardWrapper>
);
}
Field validation refers to the process of ensuring that user input meets specific criteria or constraints. When building forms or collecting data from users, validating fields helps maintain data integrity and prevents incorrect or malicious input. Here are some common aspects of field validation:
-
Data Type Validation:
- Ensuring that the input matches the expected data type (e.g., numbers, dates, strings).
- For example, validating that an age field contains a numeric value.
-
Required Fields:
- Marking certain fields as mandatory, so users must provide valid input.
- For instance, a sign-up form might require an email address.
-
Length Constraints:
- Checking if input length falls within acceptable limits (e.g., minimum and maximum characters).
- Verifying that a password meets complexity requirements.
-
Format Validation:
- Validating input based on specific patterns (e.g., email addresses, phone numbers, URLs).
- Ensuring that an email field contains a valid email format.
-
Range Validation:
- Verifying that numeric input falls within a specified range (e.g., age between 18 and 99).
- Checking if a date input is within a valid date range.
-
Custom Rules:
- Implementing custom validation logic based on business rules or specific use cases.
- For example, ensuring that a username is unique in a database.
Now let's implement the signIn server action.
We will use zod's safeParse method to validate the fields within the SignInSchema
. The method returns an object containing either the successfully parsed data or a ZodError instance containing detailed information about the validation problems.
After using safeParse(values)
, return an object with either a success or an error message.
feat: Validate user sign-in data using schema
actions\signIn.ts
"use server";
import { z } from "zod";
import { SignInSchema } from "@/schemas";
/**
* Validates user sign-in data using the provided schema.
*
* @param values - User input data to validate.
* @returns An object with either a success message or an error message.
*/
export default async function signIn(values: z.infer<typeof SignInSchema>) {
console.log(values);
const parsedValues = SignInSchema.safeParse(values);
if (!parsedValues.success) {
return {
error: "Invalid fields!",
};
}
return {
success: "Sign in successful!",
};
}
Now in the SignInForm
, add two states: successMessage
and errorMessage
.
feat: Handle sign-in response in SignInForm
- Display success message when sign-in is successful.
- Show error message when sign-in fails due to invalid fields.
feat: Show sign-in validation messages in the form
- Reset success and error messages before executing the sign-in server action.
- Pass success and error messages to relevant components for display.
- Helps the user understand the outcome of their sign-in attempt
import React, { useState, useTransition } from 'react';
export default function SignInForm() {
const [successMessage, setSuccessMessage] = useState<string | undefined>("");
const [errorMessage, setErrorMessage] = useState<string | undefined>("");
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof SignInSchema>) {
console.log(values);
// Reset success and error messages before sign-in server action
setSuccessMessage("");
setErrorMessage("");
// Handle form submission:
// - Validate the form values (type-safe and validated).
// - Execute the user sign-in server action.
startTransition(() => {
// Execute the user sign-in server action
signIn(values)
.then((data) => {
// Update success or error messages based on the server response
setSuccessMessage(data.success);
setErrorMessage(data.error);
});
});
}
return (
<CardWrapper>
<Form {...form}>
{ /* FormFields, Inputs... */}
<FormError message={errorMessage} />
<FormSuccess message={successMessage} />
{ /* Submit Button... */}
</Form>
</CardWrapper>
);
}
Create the sign-up page: /app/auth/signup/page.tsx
.
feat: Create SignUpPage component
import React from 'react';
export default function SignUpPage() {
return (
<div>SignUpPage</div>
)
}
Similar to SignInSchema
, with just another added field of username
feat: Define sign-up schema using zod
import { z } from 'zod';
export const SignUpSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: z
.string()
.min(14, 'Password must be at least 14 characters long')
.max(32, 'Password must be a maximum of 32 characters')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+={[}]|:;\"'<,>.])[A-Za-z\d!@#$%^&*()_+={[}]|:;\"'<,>.]{14,}$/, {
message: 'Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character.',
})
.refine((value) => value.length > 0, {
message: 'Password is required',
}),
username: z.string().min(1, {
message: 'Please enter a valid username',
}),
});
Notice that both SignInSchema
and SignUpSchema
both share password validation. We can create a shared schema named PasswordSchema
to avoid reuse of validation rules (DRY - Don't Repeat Yourself). This also makes it easier to discern from a glance what is validation rules are different in a schema.
refactor: Improve password validation schema
import { z } from 'zod';
const PasswordSchema = z
.string()
.min(14, 'Password must be at least 14 characters long')
.max(32, 'Password must be a maximum of 32 characters')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+={[}]|:;\"'<,>.])[A-Za-z\d!@#$%^&*()_+={[}]|:;\"'<,>.]{14,}$/, {
message: 'Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character.',
})
.refine((value) => value.length > 0, {
message: 'Password is required',
});
export const SignInSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: PasswordSchema,
});
export const SignUpSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: PasswordSchema,
username: z.string().min(1, {
message: 'Please enter a valid username',
}),
});
Now build the register or SignUpForm
component.
import React from 'react';
export default function SignUpForm() {
return (
<div>SignUpForm</div>
)
}
Let's follow the steps on building a form with react-hook-form
and zod
.
-
Create a Form Schema:
- Define the shape of your form using a Zod schema. This schema will specify the expected structure of your form data.
-
Define a Form:
- Use the
useForm
hook from react-hook-form to create a form instance. - Set up form validation, default values, and other configuration options.
- Use the
-
Build the Form
- Use the
<Form>
components (provided by your UI library) to build your form. - Include form fields, labels, error messages, and any other necessary components.
- Use the
- Create a Form Schema:
- Define the shape of your form using a Zod schema. This schema will specify the expected structure of your form data.
feat: Define sign-up schema using zod
import { z } from 'zod';
const PasswordSchema = z
.string()
.min(14, 'Password must be at least 14 characters long')
.max(32, 'Password must be a maximum of 32 characters')
.regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+={[}]|:;\"'<,>.])[A-Za-z\d!@#$%^&*()_+={[}]|:;\"'<,>.]{14,}$/, {
message: 'Password must contain at least one lowercase letter, one uppercase letter, one digit, and one special character.',
})
.refine((value) => value.length > 0, {
message: 'Password is required',
});
export const SignUpSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: PasswordSchema,
username: z.string().min(1, {
message: 'Please enter a valid username',
}),
});
- Define a Form:
- Use the
useForm
hook from react-hook-form to create a form instance. - Set up form validation, default values, and other configuration options.
- Use the
feat: Define the sign-up form with useForm hook
- Mark as client component
- Import zodResolver, useForm, z, and SignInSchema
- Use the
useForm
hook from "react-hook-form" to create a form instance. - Set up form validation, default values, and other configuration options.
"use client";
import React from 'react';
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { SignUpSchema } from '@/schemas';
export default function SignUpForm() {
// 1. Define the sign-up form.
const form = useForm<z.infer<typeof SignUpSchema>>({
resolver: zodResolver(SignUpSchema),
defaultValues: {
username: "",
email: "",
password: "",
},
});
return (
<div>SignUpForm</div>
)
}
While here we can also define the submit handler, which has the same logic as the SignInPage
. But with a different server action: signUp
.
feat: Create signUp server action to validate data
actions\signUp.ts
"use server";
import { z } from "zod";
import { SignUpSchema } from "@/schemas";
/**
* Validates user sign-up data using the provided schema.
*
* @param values - User input data to validate.
* @returns An object with either a success message or an error message.
*/
export default async function signUp(values: z.infer<typeof SignUpSchema>) {
console.log(values);
const parsedValues = SignUpSchema.safeParse(values);
if (!parsedValues.success) {
return {
error: "Invalid fields!",
};
}
return {
success: "Sign up successful!",
};
}
feat: Implement sign-up form submission handler
- Integrate useState and useTransition hooks from React
- Manage successMessage and errorMessage states
- Initialize isPending flag for smoother transitions
- Implement form submission handler for sign-up page
import React, { useState, useTransition } from 'react';
import signUp from '@/actions/signUp';
export default function SignUpForm() {
const [successMessage, setSuccessMessage] = useState<string | undefined>("");
const [errorMessage, setErrorMessage] = useState<string | undefined>("");
const [isPending, startTransition] = useTransition();
// 1. Define the sign-up form...
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof SignUpSchema>) {
console.log(values);
// Reset success and error messages before sign-up server action
setSuccessMessage("");
setErrorMessage("");
// Handle form submission:
// - Validate the form values (type-safe and validated).
// - Execute the user sign-up server action.
startTransition(() => {
// Execute the user sign-up server action
signUp(values)
.then((data) => {
// Update success or error messages based on the server response
setSuccessMessage(data.success);
setErrorMessage(data.error);
});
});
}
return (
<div>SignUpForm</div>
)
}
- Build the Form
- Use the
<Form>
components (provided by your UI library) to build your form. - Include form fields, labels, error messages, and any other necessary components.
- Use the
Similar to the sign-in form but with a few changes:
CardWrapper
props changed to reflect the sign-up pageFormField
for theusername
feat: Implement the sign-up form
- Import necessary form and UI components
- Add form fields, labels, and error messages
- Include a submit button
import CardWrapper from '@/components/auth/CardWrapper';
import FormError from '@/components/form/FormError';
import FormSuccess from '@/components/form/FormSuccess';
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
export default function SignUpForm() {
// 1. Define the sign-up form...
// 2. Define a submit handler...
// 3. Build the form
return (
<CardWrapper
backButtonHref="/auth/signin"
backButtonLabel="Already have an account?"
headerLabel="Create an account"
showSocialSignIn={true}
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className='space-y-4'>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
placeholder="Enter your username"
aria-label="username"
disabled={isPending}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder="Enter your email address"
aria-label="Email address"
disabled={isPending}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type='password'
placeholder="**************"
aria-label="Password"
disabled={isPending}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormError message={errorMessage} />
<FormSuccess message={successMessage} />
<Button
type="submit"
disabled={isPending}
className='w-full bg-sky-500'
>
Sign Up
</Button>
</form>
</Form>
</CardWrapper>
);
}
refactor: Extract SignUpForm component from SignUpPage
- Move the sign-up form logic and UI into a dedicated component
- Improve code organization and maintainability
import React from 'react';
import SignUpForm from '@/components/auth/SignUpForm';
export default function SignUpPage() {
return (
<SignUpForm />
)
}
Let's install prisma and prisma/client to run with our Next.js project.
Steps:
- Install the Prisma CLI
- Create a Prisma Schema file
- Install Prisma Client
- Instantiate a single instance of PrismaClient
npm install prisma --save-dev
You can now invoke the Prisma CLI by prefixing it with npx
:
npx prisma
Set up your Prisma ORM project by creating your Prisma Schema file with the following command:
npx prisma init
This command does two things:
-
creates a new directory called
prisma
that contains a file calledschema.prisma
, which contains the Prisma schema with your database connection variable and schema models -
creates the
.env
file in the root directory of the project, which is used for defining environment variables (such as your database connection)
Don't forget to add .env
in the gitignore
file to not commit any private information.
The boilerplate .env
file will contain some comments and a variable DATABASE_URL
. The URL is
# Environment variables declared in this file are automatically made available to Prisma.
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"
npm install @prisma/client
Best practice for instantiating Prisma Client with Next.js
Summary:
- During development, the
next dev
command clears Node.js cache on run. - This behavior initializes a new
PrismaClient
instance each time due to hot reloading, which creates a connection to the database. - However, this can quickly exhaust the database connections because each
PrismaClient
instance holds its own connection pool.
Solution: To address this issue, follow these steps:
- Instantiate a single instance of
PrismaClient
. - Save it on the
globalThis
object. - Check if
PrismaClient
is already on theglobalThis
object before instantiating a new one. If it's present, reuse the existing instance to prevent unnecessary instantiation.
This approach ensures efficient database connections and prevents excessive resource usage.
/db.ts
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
declare const globalThis: {
prismaGlobal: ReturnType<typeof prismaClientSingleton>;
} & typeof global;
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
Let's rename the file prismaSingleton.ts
and put it in a folder /db
at the root of the project.
After creating this file, you can now import the extended PrismaClient instance anywhere in your Next.js project as follows:
// e.g. in `app/page.tsx`
import prisma from '@/db/prismaSingleton';
export default function page() {
const posts = await prisma.post.findMany()
return (
<div>page</div>
)
}
Note: You can extend Prisma Client using a Prisma Client extension by appending the $extends
client method when instantiating Prisma Client as follows:
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient().$extends({
result: {
user: {
fullName: {
needs: { firstName: true, lastName: true },
compute(user) {
return `${user.firstName} ${user.lastName}`
},
},
},
},
})
}
This project uses PostgreSQL, here are the setup instructions.
When working with a PostgreSQL database, the DATABASE_URL
you receive depends on whether you have a local database on your machine or an online one.
If you're using an online database, consider using a provider like Neon. Their platform offers serverless Postgres, allowing you to build reliable and scalable applications faster. To get started, sign up for Neon, explore database branching, and connect it to your tech stack. You'll find detailed instructions in the Neon documentation.
This project will use a local PostgreSQL database.
To install on Windows
- Get Windows postgresql installer, get the Windows x86-64
- Go through the installer steps
- Confirm a password for the PostgreSQL superuser called
postgres
- Write this password down physically somewhere to be used later
- Setup port (default at 5432)
- Default locale
- Review the pre installation summary log (can be found in the directory "C:\Program Files\PostgreSQL\16\installation_summary.log" )
- Finish installation
- Skip or cancel Stack Builder
Create an .env
file. Add an environment variable for the postgresql connection URI.
Inside the .env
file create a DATABASE_URL
variable. This will store the connection URI string to our local database.
An example connection URI string should be something like this:
.env
DATABASE_URL="postgresql://myname:mypassword@localhost:5432/mydb?schema=public"
-
Provider: The
provider
specifies the type of database you're connecting to. In this case, it's PostgreSQL. -
URL Components:
- User:
"myname"
is the username for the database. - Password:
"mypassword"
is the password for the user. - Host:
"localhost"
refers to the machine where the PostgreSQL server is running. - Port:
5432
is the default port for PostgreSQL. - Database Name:
"mydb"
is the name of the database. - Schema:
"public"
specifies the schema within the database.- If you omit the schema, Prisma will use the
"public"
schema by default
- If you omit the schema, Prisma will use the
- User:
So, the complete URL connects to a PostgreSQL database with the given credentials and schema. If you're using Prisma, this URL allows Prisma ORM to connect to your database when executing queries with Prisma Client or making schema changes with Prisma Migrate. If you need to make the URL dynamic, you can pass it programmatically when creating the Prisma Client.
To connect to a PostgreSQL database server, you need to configure a datasource block in your Prisma schema file:
schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
The fields passed to the datasource block are:
provider
: Specifies thepostgresql
data source connector.url
: Specifies the connection URL for the PostgreSQL database server. In this case, an environment variable is used to provide the connection URL.
Or without environment variables (not recommended):
datasource db {
provider = "postgresql"
url = "postgresql://myname:mypassword@localhost:5432/mydb?schema=public"
}
Let's look at the spec for a PostgreSQL connection URI:
postgres[ql]://[username[:password]@][host[:port],]/database[?parameter_list]
\_____________/\____________________/\____________/\_______/\_______________/
| | | | |
|- schema |- userspec | | |- parameter list
| |
| |- database name
|
|- hostspec
We can test a PostgreSQL connection string in the terminal by running the command pg_isready
pg_isready -d DATABASE_NAME -h HOST_NAME -p PORT_NUMBER -U DATABASE_USER
For MySQL, PostgreSQL and CockroachDB you must percentage-encode special characters in any part of your connection URL - including passwords. For example, p@$$w0rd
becomes p%40%24%24w0rd
.
For Microsoft SQL Server, you must escape special characters in any part of your connection string.
Ensure that the DATABASE_URL
is properly configured in your .env
file. With the database setup complete we can build our data models within the schema.prisma
.
Here is the boilerplate schema.prisma
file provided by the npx prisma init
command:
prisma\schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
The Prisma schema serves as the central configuration for your Prisma setup. Typically named schema.prisma
, it fulfills several crucial roles:
-
Data Model Definition: In an intuitive data modeling language, developers define their application models within the Prisma schema. This includes specifying entities, relationships, and fields.
-
Database Connection: The Prisma schema establishes the connection to your database. It contains information about the database provider, connection URL, and other relevant settings.
-
Generator Configuration: You can define code generators in the Prisma schema. These generators create the necessary code for your application based on the data model. For example, Prisma generates TypeScript or JavaScript code for your database queries and mutations.
In summary, the Prisma schema acts as the bridge between your application's data model and the underlying database, providing a clear and structured way to manage your data access layer. For more specific details or examples related to the Prisma schema, see the official documentation.
In a Prisma schema, you define your application models (also known as Prisma models) using a concise syntax.
Models: These represent the entities in your application domain. They map to tables (in relational databases like PostgreSQL) or collections (in MongoDB). Models form the foundation for queries available in the generated Prisma Client API.
-
Example Schema:
model User { id Int @id @default(autoincrement()) email String @unique name String? role Role @default(USER) posts Post[] profile Profile? } model Profile { id Int @id @default(autoincrement()) bio String user User @relation(fields: [userId], references: [id]) userId Int @unique } model Post { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt title String published Boolean @default(false) author User @relation(fields: [authorId], references: [id]) authorId Int categories Category[] } model Category { id Int @id @default(autoincrement()) name String posts Post[] } enum Role { USER ADMIN }
-
Explanation:
- The schema defines models like
User
,Profile
,Post
, andCategory
. - Fields within models (e.g.,
email
,name
, etc.) correspond to columns in the database. - Relationships (e.g.,
@relation
,@unique
) define how models relate to each other. - Enums (like
Role
) allow you to define fixed sets of values.
- The schema defines models like
-
Generated Prisma Client:
- Prisma Client generates type-safe code for your models, making database access safe and efficient.
- You can create records, query data, and perform mutations using Prisma Client.
Remember, your data model reflects your application's domain. Whether it's an ecommerce app (with models like Customer
, Order
, etc.) or a social media platform (with User
, Post
, etc.), the Prisma schema captures the essence of your data structure.
Let's convert the Zod schema for sign-up into a Prisma schema model. Prisma models define the data structure for the database, and we'll map the relevant fields from the Zod schema to Prisma models.
First, identify the relevant fields in SignUpSchema
Zod schema:
schemas\index.ts
export const SignUpSchema = z.object({
email: z.string().email({
message: 'Please enter a valid email address.',
}),
password: PasswordSchema,
username: z.string().min(1, {
message: 'Please enter a valid username',
}),
});
email
: Represents the user's email address.password
: Represents the user's password.username
: Represents the user's chosen username.
Now, let's create a Prisma model named User
that corresponds to these fields:
prisma\schema.prisma
model User {
id String @id @default(cuid())
email String @unique
password String // You can choose the appropriate type for password storage (e.g., hashed)
username String? // Optional username
}
Here's how we map the fields:
email
: A unique string field (@unique
) to store the user's email.password
: A string field to store the user's password (you can choose the appropriate type for password storage, such as hashed).username
: An optional string field to store the user's chosen username.
In the terminal we can run the command npx prisma
to see the list of commands to query, migrate and model the database.
npx prisma
After defining a prisma model, run the following command to generate artifacts:
npx prisma generate
Now we can access the User
model via the Prisma Client Singleton instance. Here is an example:
import React from 'react';
import prisma from '@/db/prismaSingleton';
export default async function page() {
const users = await prisma.user.findMany();
return ( <div>page</div> );
}
To push the Prisma schema state to the database:
npx prisma db push
Let's follow the steps in the authjs prisma adapter docs.
npm install @prisma/client @auth/prisma-adapter
npm install prisma --save-dev
Or just install @auth/prisma-adapter
:
npm install @auth/prisma-adapter
DATABASE_URL=postgres://postgres:adminadmin@0.0.0.0:5432/db
Create the Auth.js config file and object. This is where you can control the behaviour of the library and specify custom authentication logic, adapters, etc. In this file we'll pass in all the options to the framework specific initalization function and then export the route handler(s), signin and signout methods, and more.
./auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { PrismaClient } from "@prisma/client"
const prisma = new PrismaClient()
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [],
})
We can find a specific schema thats required by Auth.js.
prisma/schema-postgres.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
// Optional for WebAuthn support
Authenticator Authenticator[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
model Session {
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
identifier String
token String
expires DateTime
@@id([identifier, token])
}
// Optional for WebAuthn support
model Authenticator {
credentialID String @unique
userId String
providerAccountId String
credentialPublicKey String
counter Int
credentialDeviceType String
credentialBackedUp Boolean
transports String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, credentialID])
}
Let's break down the schema:
-
Database Connection: The schema defines the database connection. You specify the data source (e.g., PostgreSQL, MySQL) and any relevant connection details.
-
Generator: The schema specifies the code generator that Prisma should use to generate the Prisma Client.
-
The
generator
block in the Prisma schema specifies the code generator that Prisma should use to generate the Prisma Client. Let's break it down: -
Provider: The
provider
field specifies the language or platform for which you want to generate the Prisma Client. The"prisma-client-js"
generates a JavaScript client for the Prisma schema. -
Prisma supports other providers as well. For example:
"prisma-client-java"
generates a Java client.
-
When you run
prisma generate
, Prisma uses this information to create a client library that allows you to interact with your database using JavaScript.
-
Data Model: The schema also contains the data model. It defines the structure of the database tables, including their fields, relationships, and constraints.
Account
: Represents user accounts with fields likeid
,userId
,type
, andprovider
.Session
: Represents user sessions with fields likeid
,sessionToken
, andexpires
.User
: Represents users with fields likeid
,name
,email
, and relationships toAccount
andSession
.VerificationToken
: Represents verification tokens with fields likeidentifier
,token
, andexpires
.
-
Directives:
@id
: Indicates the primary key field.@default(cuid())
: Specifies a default value (e.g., a unique identifier).@map("...")
: Maps the field name to a specific column name in the database.@unique
: Ensures uniqueness for the specified fields.@db.Text
: Specifies that the field should be stored as text in the database.@relation
: Defines relationships between models (e.g.,user User @relation(fields: [userId], references: [id], onDelete: Cascade)
).
-
Table Names:
- Use
@@map("...")
to customize the table names in the database (e.g.,"accounts"
for theAccount
model).
- Use
Note: Prisma's @map()
feature allows you to change the field names and customize the column names to whichever naming convention you prefer.
Using lowercase table names like "users" or "accounts" has some advantages:
-
Consistency and Convention:
- Lowercase table names follow common naming conventions in databases.
- Consistency across your schema makes it easier for developers (including yourself) to understand and maintain the codebase.
-
Compatibility with Database Systems:
- Some database systems (e.g., PostgreSQL) treat table names as case-insensitive by default.
- Using lowercase ensures compatibility across different databases.
-
Readability and Clarity:
- Lowercase names are more readable and concise.
- They avoid confusion with other identifiers (e.g., column names, function names).
-
URLs and Routes:
- If you use table names in URLs or routes (e.g., for REST APIs), lowercase names are cleaner and more SEO-friendly.
Remember that while lowercase names have these advantages, the most important factor is consistency within your project. Choose a naming convention that aligns with your team's preferences and project requirements.
Let's build the schema step-by-step.
Changes from the User
model from the docs:
- Changed
name
field tousername
- Removed
Authenticator
for WebAuthn support
feat: Update User model fields in schema
model User {
id String @id @default(cuid())
username String?
email String @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Notice that the provided User
model does not have the password
field. That's because Auth.js does not use credentials by default but does have support for Credentials Provider | Authjs.
Add an optional password field in the User model. This option is necessary because OAuth providers such as Google or GitHub should be able to create a User model without requiring a password.
feat: Add password field to User model
model User {
id String @id @default(cuid())
username String?
email String @unique
emailVerified DateTime?
image String?
password String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
feat: Add Account model in prisma schema
model Account {
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([provider, providerAccountId])
}
feat: Add Session model in prisma schema
model Session {
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
We want to save the values
from the user sign-up data after validation in the signUp
server action.
We need to encrypt the password data in the user sign-up process to greatly improve the security of our users.
For that we delegate the password hashing to a library named bcrypt. Additionally, we need to install bcrypt type definitions with @types/bcrypt.
Install bcrypt:
npm i bcrypt
Install @types/bcrypt as dev dependency
npm i -D @types/bcrypt
Here is an example of how to use bcrypt
import * as bcrypt from 'bcrypt';
const saltRounds = 10;
const myPlaintextPassword = 's0/\/\P4$$w0rD';
(async () => {
// Technique 1 (generate a salt and hash on separate function calls):
const salt = await bcrypt.genSalt(saltRounds);
const hash = await bcrypt.hash(myPlaintextPassword, salt);
// Store hash in your password DB.
// Technique 2 (auto-gen a salt and hash):
const hash2 = await bcrypt.hash(myPlaintextPassword, saltRounds);
// Store hash in your password DB.
})();
Now go to the signUp
action and import bcrypt
to generate the hash from the password.
feat(auth): Add password hashing using bcrypt
feat: Implement password hashing in signUp action
This commit introduces password hashing using bcrypt in the sign-up function. The user's password is securely hashed with a randomly generated salt, ensuring better security.
"use server";
import bcrypt from 'bcrypt';
import { z } from "zod";
import { SignUpSchema } from "@/schemas";
export default async function signUp(values: z.infer<typeof SignUpSchema>) {
console.log(values);
const parsedValues = SignUpSchema.safeParse(values);
if (!parsedValues.success) {
return {
error: "Invalid fields!",
};
}
// Extract the data from the parsed values
const { email, password, username } = parsedValues.data;
// Hash the password
const saltRounds = 10;
const salt = await bcrypt.genSalt(saltRounds);
const hash = await bcrypt.hash(password, salt);
return {
success: "Sign up successful!",
};
}
Next find if we already have an existing user.
feat(auth): Verify unique email before signUp
When a user signs up, verify that the email address is unique and does not already exist in the database before allowing registration or updating the email.
feat: Check for existing user in signUp action
import bcrypt from "bcrypt";
import { z } from "zod";
import prisma from "@/db/prismaSingleton";
import { SignUpSchema } from "@/schemas";
export default async function signUp(values: z.infer<typeof SignUpSchema>) {
console.log(values);
const parsedValues = SignUpSchema.safeParse(values);
if (!parsedValues.success) {
return {
error: "Invalid fields!",
};
}
const { email, password, username } = parsedValues.data;
const saltRounds = 10;
const salt = await bcrypt.genSalt(saltRounds);
const hash = await bcrypt.hash(password, salt);
// Check if an existing user with the given email exists
const existingUser = await prisma.user.findUnique({
where: {
email,
},
});
if (existingUser) {
return { error: "Email address is already in use." };
}
return {
success: "Sign up successful!",
};
}
Let's refactor out the existing user logic into separate utility function.
feat: Create user record retrieval utilities
refactor: Extract existing user logic into utility
utils\getUserByEmail.ts
import prisma from "@/db/prismaSingleton";
export default async function getUserByEmail(email: string) {
try {
const user = await prisma.user.findUnique({
where: {
email,
},
});
return user;
} catch {
return null;
}
}
Now use the function getUserByEmail
inside the signUp action:
import getUserByEmail from "@/utils/getUserByEmail";
export default async function signUp(values: z.infer<typeof SignUpSchema>) {
// ...
// Check if an existing user with the given email exists
const existingUser = await getUserByEmail(email);
Let's improve the error handling for the getUserByEmail
function. We know that we can expect various errors from Prisma Client by checking the prisma client error reference.
feat: Improve error handling in getUserByEmail
import { Prisma } from "@prisma/client";
import prisma from "@/db/prismaSingleton";
/**
* Retrieves a user by their email address.
*
* @param email - The email address to search for.
* @returns The user object if found, or null if not found or an error occurs
* @see {@link https://www.prisma.io/docs/orm/reference/error-reference} Prisma error reference
*/
export default async function getUserByEmail(email: string) {
try {
// Check if an existing user with the given email exists
const user = await prisma.user.findUnique({
where: {
email,
},
});
return user;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// Handle specific Prisma errors (e.g., database connection issue)
console.error("Prisma error:", error.message);
} else {
// Handle other unexpected errors
console.error("Unexpected error:", error);
}
return null;
}
}
While here let's also create a similar utility function getUserById
, that retrieves a User
record by their ID. We will use these utility functions in our authentication callbacks later when we need more information from the database.
feat: Create getUserById utility function
utils\getUserById.ts
import { Prisma } from "@prisma/client";
import prisma from "@/db/prismaSingleton";
/**
* Retrieves a user by their ID.
*
* @param id - The user ID to search for.
* @returns The user object if found, or null if not found or an error occurs
* @see {@link https://www.prisma.io/docs/orm/reference/error-reference} Prisma error reference
*/
export default async function getUserById(id: string) {
try {
// Check if an existing user with the given ID exists
const user = await prisma.user.findUnique({
where: {
id,
},
});
return user;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// Handle specific Prisma errors (e.g., database connection issue)
console.error("Prisma error:", error.message);
} else {
// Handle other unexpected errors
console.error("Unexpected error:", error);
}
return null;
}
}
feat(auth): Create user during signUp
feat: Create user record in signUp server action
"use server";
import bcrypt from "bcrypt";
import { z } from "zod";
import prisma from "@/db/prismaSingleton";
import { SignUpSchema } from "@/schemas";
/**
* Validates user sign-up data using the provided schema.
*
* @param values - User input data to validate.
* @returns An object with either a success message or an error message.
*/
export default async function signUp(values: z.infer<typeof SignUpSchema>) {
console.log(values);
const parsedValues = SignUpSchema.safeParse(values);
if (!parsedValues.success) {
return {
error: "Invalid fields!",
};
}
// Extract the data from the parsed values
const { email, password, username } = parsedValues.data;
// Hash the password
const saltRounds = 10;
const salt = await bcrypt.genSalt(saltRounds);
const hash = await bcrypt.hash(password, salt);
// Check if an existing user with the given email exists
const existingUser = await prisma.user.findUnique({
where: {
email,
},
});
if (existingUser) {
return { error: "Email address is already in use." };
}
// Create a new user in the database with the provided sign-up data:
// username, email, and hashed password
await prisma.user.create({
data: {
username,
email,
password: hash,
}
});
return {
success: "Sign up successful!",
};
}
- Upgrade Guide NextAuth.js v5 do this only if you are upgrading from a previous version of
next-auth
- Installation Guide Auth.js for a fresh install
npm install next-auth@beta
The only environment variable that is mandatory is the AUTH_SECRET
.
- This is a random value used by the library to encrypt tokens and email verification hashes.
- (See Deployment to learn more). You can generate one via the official Auth.js CLI running:
npx auth secret
This will also add it to your .env
file, respecting the framework conventions (eg.: Next.js' .env.local
).
Next, create the Auth.js config file and object. This is where you can control the behaviour of the library and specify custom authentication logic, adapters, etc. We recommend all frameworks to create an auth.ts
file in the project. In this file we'll pass in all the options to the framework specific initalization function and then export the route handler(s), signin and signout methods, and more.
- Start by creating a new
auth.ts
file at the root of your app with the following content.
./auth.ts
import NextAuth from "next-auth"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [],
})
- Add a Route Handler under
/app/api/auth/[...nextauth]/route.ts
.
./app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
- Add optional Middleware to keep the session alive, this will update the session expiry every time its called.
./middleware.ts
export { auth as middleware } from "@/auth"
feat: Configure NextAuth with empty providers feat: Initialize NextAuth route handlers feat: Extend session expiration on middleware call
feat: Configure selective middleware paths
For advanced use cases, you can use auth
as a wrapper for your Middleware:
middleware.ts
import { auth } from "@/auth"
export default auth((req) => {
// req.auth
})
// Optionally, don't invoke Middleware on some paths
export const config = {
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
-
Check out additional Middleware docs for more details.
-
Also see Middleware | Nextjs App Router
Middleware will be invoked for every route in your project. Given this, it's crucial to use matchers to precisely target or exclude specific routes.
The following is the execution order:
headers
fromnext.config.js
redirects
fromnext.config.js
- Middleware (
rewrites
,redirects
, etc.) beforeFiles
(rewrites
) fromnext.config.js
- Filesystem routes (
public/
,_next/static/
,pages/
,app/
, etc.) afterFiles
(rewrites
) fromnext.config.js
- Dynamic Routes (
/blog/[slug]
) fallback
(rewrites
) fromnext.config.js
There are two ways to define which paths Middleware will run on:
Middleware example:
Suppose you want middleware to apply only to paths starting with /auth/signin
, here is how you would configure it:
import { auth } from "@/auth"
export default auth((req) => {
// req.auth
console.log("ROUTE: ", req.nextUrl.pathname);
})
export const config = {
matcher: ["/auth/signin"],
}
Now if you navigate to localhost:3000/auth/signin
we can see in the terminal the route is logged so the middleware was invoked. On the other hand, if you navigate to the /auth/signup
path the middleware is not invoked.
The config
is simply and object with a matcher
to be able to invoke the middleware (i.e., auth
function).
feat: Configure selective middleware paths
Specify regular expressions to match request paths, excluding API routes, static files, image optimization files, and favicon.ico.
From Next.js
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
feat: Configure middleware paths
Specify regular expressions to match request paths, excluding Next.js internals and all static files (unless found in search params). Always run for API routes.
From Clerk middleware
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
With that, the basic setup is complete! Next we'll setup the first authentication methods and fill out that providers
array. See Authentication Auth.js Reference.
Next, create the main Auth.js configuration file which contains the necessary configuration for Auth.js, as well as the dynamic route handler.
./auth.ts
import NextAuth from "next-auth"
import GitHub from "next-auth/providers/github"
export const { handlers, auth } = NextAuth({
providers: [GitHub],
})
We can update our earlier auth.ts
file with the GitHub provider:
feat: Add GitHub provider to NextAuth
auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [GitHub],
});
feat: Implement catch-all route for Auth.js API
./app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
export const runtime = "edge" // optional
Since this is a catch-all dynamic route, it will respond to all the relevant Auth.js API routes so that your application can interact with the chosen OAuth provider using the OAuth 2 protocol.
refactor: Remove edge runtime compatibility
./app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers
Note: Since we are using prisma we can omit the runtime = edge
as prisma adapter may not be fully edge compatible. See Auth.js Edge Compatibility.
If you haven't, create an .env.local
file as explained in the installation section and add the following two GitHub variables:
.env.local
AUTH_SECRET="changeMe"
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
We will be filling AUTH_GITHUB_ID
and AUTH_GITHUB_SECRET
with proper values from the GitHub Developer Portal once we have registered our application in GitHub.
Creating an OAuth App in GitHub
To get the required credentials from GitHub, we need to create an application in their developer settings.
- Go to the GitHub developer settings, also found under Settings → Developers → OAuth Apps, and click “New OAuth App”:
- Next, you'll be presented with a screen to register your application. Fill in all the required fields.
- The default callback URL should generally take the form of
[origin]/api/auth/callback/[provider]
, however, the default is slightly different depending on which framework you're using.
Next.js
// Local
http://localhost:3000/api/auth/callback/github
// Prod
https://app.company.com/api/auth/callback/github
- Once you've entered all the required fields, press “Register application”.
Secrets
-
After successfully registering your application, GitHub will present us with the required details.
-
We need 2 things from this screen, the Client ID and Client Secret.
-
The Client ID is always visible, it is a public identifier of your OAuth application within GitHub.
-
To get a Client Secret, you have to click on “Generate a new client secret”, which will create your first client secret. You can easily create a new client secret here in case your first one gets leaked, lost, etc.
-
Important: Keep your Client Secret secure and never expose it to the public or share it with people outside your organization.
Now that we have the required Client ID and Client Secret, paste them into your .env.local
file we created earlier.
.env.local
AUTH_SECRET="changeMe"
AUTH_GITHUB_ID={clientId}
AUTH_GITHUB_SECRET={clientSecret}
With all the pieces in place, you can now start your local dev server and test the login process.
npm run dev
Navigate to http://localhost:3000
. You should see the following page:
Click on “Sign in”, you should be redirected to the default Auth.js signin page. You can customize this page to fit your needs. Next, click on “Sign in with GitHub”. Auth.js will redirect you to GitHub, where GitHub will recognize your application and ask the user to confirm they want to authenticate to your new application by entering their credentials.
Once authenticated, GitHub will redirect the user back to your app and Auth.js will take care of the rest:
If you've landed back here that means everything worked! We have completed the whole OAuth authentication flow so that users can log in to your application via GitHub!
Note: As you can see, most of the time required setting up OAuth in your application is spent registering your application in the OAuth provider's dashboard (some are easier to navigate, some are harder). Once registered, the setup via Auth.js should be straight forward.
Before you can release your app to production, you'll need to change a few things.
Unfortunately, GitHub is among the providers which do not let you register multiple callback URLs for one application. Therefore, you'll need to register a separate application in GitHub's dashboard as we did previously but set the callback URL to your application's production domain (.i.e https://example.com/api/auth/callback/github
). You'll then also have a new Client ID and Client Secret that you need to add to your production environment via your hosting provider's dashboard (Vercel, Netlify, Cloudflare, etc.) or however you manage environment variables in production.
Refer to the Deployment page for more information.
Since we are using Prisma Client, prisma adapter with authjs and a local postgreSQL database our app will not be edge ready (i.e., engineered the software to avoid any of the Node.js features/modules that are missing in some of the edge runtimes). See more on edge compatibility from Authjs.
- Prisma Client - Edge functions
- Authjs edge compatibility
- Authjs migrating to v5 | edge compatibility
Auth.js supports two session strategies. When you are using an adapter, it will default to the database strategy. Unless your database and its adapter is compatible with the Edge runtime/infrastructure, you will not be able to use the "database"
session strategy.
So for example, if you are using an adapter that relies on an ORM/library that is not yet compatible with Edge runtime(s) below is an example where we force the jwt
strategy and split up the configuration so the library doesn't attempt to access the database in edge environments, like in the middleware.\
Here is our current auth.ts
and middleware.ts
files respectively:
auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [GitHub],
});
middleware.ts
import { auth } from "@/auth";
export default auth((req) => {
// req.auth
console.log("ROUTE: ", req.nextUrl.pathname);
});
const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
-
Database Adapters and Auth.js:
- Auth.js, when paired with a database client, forms a holistic authentication system.
- Database clients often use TCP sockets to communicate directly with the database server.
- PostgreSQL is a common database that follows this approach.
-
Edge Runtimes and Limitations:
- Edge runtimes are server-side JavaScript runtimes optimized for lower-power hardware closer to users.
- These runtimes lack certain features/modules available in Node.js.
- For example, raw TCP sockets are generally not available in edge runtimes.
-
Solving the Problem:
- To work around this limitation, some solutions involve using an API server.
- The API server translates HTTP requests into a protocol the database can understand.
- This allows client-side code to make HTTP requests to the API server, which is universally supported by edge runtimes.
-
Middleware in Next.js:
- Next.js Middleware can protect routes by checking session existence and determining the next route.
- Middleware code runs in edge runtimes by default.
- When using a non-"edge compatible" database adapter (like PostgreSQL), we need to find alternative ways to query the database.
In summary, to handle database communication in edge runtimes, consider using an API server and explore alternative approaches to querying databases. Middleware in Next.js can also help protect routes based on session information.
Therefore, to use a database adapter that isn't explicitly "edge compatible", we will need to find a way to query the database using the features that we do have available to us.
-
Auth.js and Database Sessions:
- Auth.js, when paired with a database session strategy and a database adapter, makes frequent database calls during normal operations.
- It checks if a user's session token is valid by querying the database.
- Every
auth()
call triggers a database query. - Auth.js uses caching and other optimizations to minimize unnecessary requests.
-
Edge Runtimes and Workaround:
- Edge runtimes lack certain features (like raw TCP sockets).
- To use Auth.js in edge runtimes with various database adapters, we need a workaround.
- Consider creating separate versions of next-auth:
- One without database settings for edge environments.
- Another with database settings for other environments.
- Use "lazy initialization" to instantiate clients accordingly.
Instructions:
- First, a common Auth.js configuration object to be used everywhere. This will not include the database adapter.
- Next, a separate instantiated Auth.js instance which imports that configuration, but also adds the adapter and using
jwt
for the Session strategy: - Our Middleware, which would then import the configuration without the database adapter and instantiate its own Auth.js client.
- Finally, everywhere else we can import from the primary
auth.ts
configuration and usenext-auth
as usual. See our session management docs for more examples.
Note:
It is important to note here that we've now removed database functionality and support from next-auth
in the middleware. That means that we won't be able to fetch the session or other info like the user's account details, etc. while executing code in middleware. That means you'll want to rely on checks like the one demonstrated above in the /app/protected/page.tsx
file to ensure you're protecting your routes effectively. Middleware is then still used for bumping the session cookie's expiry time, for example.
Let's implement the solution.
feat(auth): Separate edge and non-edge versions
To address the edge runtime limitations, we create two versions of next-auth:
- An edge-specific version without database settings.
- A standard version with database support for other environments.
We achieve this using Auth.js "lazy initialization" to instantiate clients accordingly. We implement the split config workaround solution.
For more details refer to: Auth.js Edge Compatibility Guide
1. First, a common Auth.js configuration object to be used everywhere. This will not include the database adapter.
auth.config.ts
import GitHub from "next-auth/providers/github"
import type { NextAuthConfig } from "next-auth"
// Notice this is only an object, not a full Auth.js instance
export default {
providers: [GitHub],
} satisfies NextAuthConfig
2. Next, a separate instantiated Auth.js instance which imports that configuration, but also adds the adapter and using jwt
for the Session strategy:
auth.ts
import NextAuth from "next-auth"
import authConfig from "./auth.config"
import { PrismaClient } from "@prisma/client"
import { PrismaAdapter } from "@auth/prisma-adapter"
const prisma = new PrismaClient()
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
...authConfig,
})
feat(auth): Configure NextAuth with PrismaAdapter
- Create a separate Auth.js instance using the configuration from auth.config.ts
auth.config.ts
- Add the Prisma Adapter
- Add JWT session strategy
Let's also use our PrismaSingleton
instead of instantiating a new PrismaClient
here.
feat: Configure NextAuth with prisma singleton
auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter"
import authConfig from "@/auth.config";
import prisma from "@/db/prismaSingleton";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
...authConfig,
});
3. Our Middleware, which would then import the configuration without the database adapter and instantiate its own Auth.js client.
middleware.ts
import NextAuth from "next-auth"
import authConfig from "./auth.config"
export const { auth: middleware } = NextAuth(authConfig)
Let's adapt it so that we export the auth()
function as the middleware
and also include our config
matcher.
feat: Create custom middleware with config matcher
This commit modifies the middleware.ts
file to export the auth()
function as middleware
. Additionally, it includes a custom config
matcher for route handling.
Changes:
- Import Auth.js configuration object without the database adapter
- Instantiate its own Auth.js client
- Renamed
auth
tomiddleware
for clarity. - Added a custom
config
object with route matchers.
import NextAuth from "next-auth";
import authConfig from "@/auth.config";
const { auth: middleware } = NextAuth(authConfig);
export default middleware((req) => {
console.log("ROUTE: ", req.nextUrl.pathname);
});
const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
refactor(auth): Rename custom middleware to auth
import NextAuth from "next-auth";
import authConfig from "@/auth.config";
const { auth } = NextAuth(authConfig);
export default auth((req) => {
console.log("ROUTE: ", req.nextUrl.pathname);
});
const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
Notice that in step 3 of authjs - migrating to v5 edge compatibility we have this instruction:
- In your middleware file, import the configuration object from your first
auth.config.ts
file and use it to lazily initialize Auth.js there. In effect, initialize Auth.js separately with all of your common options, but without the edge incompatible adapter.
Example:
middleware.ts
import authConfig from "./auth.config"
import NextAuth from "next-auth"
// Use only one of the two middleware options below
// 1. Use middleware directly
// export const { auth: middleware } = NextAuth(authConfig)
// 2. Wrapped middleware option
const { auth } = NextAuth(authConfig)
export default auth(async function middleware(req: NextRequest) {
// Your custom middleware logic goes here
})
The main idea, is to separate the part of the configuration that is edge-compatible from the rest, and only import the edge-compatible part in Middleware/Edge pages/routes. You can read more about this workaround in the Prisma docs, for example.
We will go with the wrapped middleware approach.
feat(auth): Implement wrapped middleware option
import { NextRequest } from "next/server";
import NextAuth from "next-auth";
import authConfig from "@/auth.config";
const { auth } = NextAuth(authConfig);
export default auth(async function middleware(req: NextRequest) {
console.log("ROUTE: ", req.nextUrl.pathname);
});
const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
4. Finally, everywhere else we can import from the primary auth.ts
configuration and use next-auth
as usual.
app/protected/page.tsx
import { auth } from "@/auth"
export default async function Page() {
const session = await auth()
if (!session) {
return <div>Not authenticated</div>
}
return (
<div className="container">
<pre>{session}</pre>
</div>
)
}
It is important to note here that we've now removed database functionality and support from
next-auth
in the middleware. That means that we won't be able to fetch the session or other info like the user's account details, etc. while executing code in middleware. That means you'll want to rely on checks like the one demonstrated above in the/app/protected/page.tsx
file to ensure you're protecting your routes effectively. Middleware is then still used for bumping the session cookie's expiry time, for example.
Let's see how our split config and Auth.js all comes together.
Create app\(protected)\settings\page.tsx
, a SettingsPage
which will be located in the route localhost:3000/settings
.
import React from 'react';
export default function SettingsPage() {
return (
<div>SettingsPage</div>
)
}
To import and use next-auth
in our protected settings page we need to do the following:
- import primary
auth.ts
configuration - Convert function to
async
await
theauth()
function to get thesession
- Render the session value
feat: Add authentication to settings page
import React from 'react';
import { auth } from '@/auth';
export default async function SettingsPage() {
const session = await auth();
return (
<div>
{JSON.stringify(session)}
</div>
)
}
-
Imports:
- It imports the
React
library and theauth
function from the@/auth
module. - The
auth
function seems to be related to authentication.
- It imports the
-
Function Component:
- The
SettingsPage
function is an asynchronous function component. - It awaits the result of the
auth()
function.
- The
-
Rendering:
- Inside the component, it renders a
<div>
that displays the result ofJSON.stringify(session)
.
- Inside the component, it renders a
-
Session Handling:
- The
session
variable is assigned the result ofawait auth()
. - The
auth()
function is responsible for fetching or managing user authentication data.
- The
-
Output:
- The rendered output will show the stringified
session
object within a<div>
.
- The rendered output will show the stringified
When we load the page at localhost:3000/settings
we should see that the session
value is null
because the user is not signed-in and authenticated.
If the user is signed-out they should not be able to access the settings page.
First let's create a routes.ts
file at the root of the application. The file will contain various routes and endpoints. For now create two arrays which contain public routes and protected routes respectively.
feat(routes): Implement access control routes feat(routes): Define global array for publicRoutes feat(routes): Define array for protected routes
routes.ts
/**
* An array of public routes accessible to all users.
* These routes do not require authentication.
* @type {string[]}
*/
export const publicRoutes: string[] = [
"/",
];
/**
* An array of protected routes that require authentication.
* @type {string[]}
*/
export const protectedRoutes: string[] = [
"/auth/signin",
"/auth/signup",
];
In summary, publicRoutes
are open to everyone, while protectedRoutes
are accessible only to authenticated users. These definitions help organize our application's routing and ensure proper access control.
-
publicRoutes
:- These are routes accessible to all users, regardless of whether they are authenticated or not.
- Typically, public routes include pages like the home page, landing pages, or informational content that doesn't require authentication.
- Examples:
/
: The root/home page./about
: An "About Us" page./contact
: A contact form.
- By defining these routes, we ensure that unauthenticated users can access them without any restrictions.
-
protectedRoutes
:- These routes require user authentication. Only authenticated users can access them.
- Protected routes often include user-specific content, dashboards, account settings, or any feature that requires authentication.
- Examples:
/profile
: User profile page./dashboard
: User dashboard with personalized data./settings
: Account settings page.
- By defining these routes, we restrict access to authenticated users only, ensuring that sensitive or personalized information remains secure.
Let's also add a prefix for the route /api/auth
which we can name authApiRoute
. We add this special case to ensure that we always allow the API to be available to all users regardless of authentication.
feat(routes): Add API authentication base endpoint
routes.ts
/**
* The base endpoint for API authentication routes.
* Routes that start with this prefix are dedicated to API authentication.
* @type {string}
*/
export const apiAuthRoute: string = "/api/auth";
feat(auth): Define default sign-in redirect path
/**
* The default redirect path after user sign-in and authentication.
* This path is used when no specific redirect is provided.
* @type {string}
*/
export const DEFAULT_SIGNIN_REDIRECT: string = "/settings";
feat(auth): Add route authorization in middleware feat(auth): Implement custom middleware for routes
Back in middleware.ts
let's first extract the pathname
from the req.nextUrl
to improve code clarity:
refactor(auth): Improve readability in middleware
This commit refactors the custom middleware by extracting the pathname from the request object. The use of a single pathname
variable throughout the logic improves code clarity and readability.
middleware.ts
import { NextRequest } from "next/server";
import NextAuth from "next-auth";
import authConfig from "@/auth.config";
const { auth } = NextAuth(authConfig);
export default auth(async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl; // Destructure the pathname from req.nextUrl
console.log("ROUTE: ", pathname); // Debug statement that logs current route
});
const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
Modify the auth()
function in the middleware to handle different routes.
feat(auth): Allow api auth routes in middleware
import { NextRequest } from "next/server";
import NextAuth from "next-auth";
import authConfig from "@/auth.config";
import { apiAuthRoute } from "@/routes";
const { auth } = NextAuth(authConfig);
export default auth(async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl; // Destructure the pathname from req.nextUrl
const isApiAuthRoute = pathname.startsWith(apiAuthRoute);
});
feat(auth): Add public routes to custom middleware
import {
apiAuthRoute,
publicRoutes
} from "@/routes";
const { auth } = NextAuth(authConfig);
export default auth(async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const isApiAuthRoute = pathname.startsWith(apiAuthRoute);
const isPublicRoute = publicRoutes.includes(pathname);
});
feat(auth): Add protected routes to middleware
import {
apiAuthRoute,
protectedRoutes,
publicRoutes
} from "@/routes";
const { auth } = NextAuth(authConfig);
export default auth(async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const isApiAuthRoute = pathname.startsWith(apiAuthRoute);
const isPublicRoute = publicRoutes.includes(pathname);
const isProtectedRoute = protectedRoutes.includes(pathname);
});
Now let's start the authorization logic.
feat(auth): Allow access to API auth routes
export default auth(async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl; // Destructure the pathname from req.nextUrl
console.log("ROUTE: ", pathname); // Debug statement that logs current route
const isApiAuthRoute = pathname.startsWith(apiAuthRoute);
const isPublicRoute = publicRoutes.includes(pathname);
const isProtectedRoute = protectedRoutes.includes(pathname);
if(isApiAuthRoute) {
// Allow access to /api/auth routes
return;
}
});
feat: Handle protected routes in middleware
import {
apiAuthRoute,
DEFAULT_SIGNIN_REDIRECT,
protectedRoutes,
publicRoutes
} from "@/routes";
import { getSession } from "next-auth/react";
const { auth } = NextAuth(authConfig);
export default auth(async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl; // Destructure the pathname from req.nextUrl
console.log("ROUTE: ", pathname); // Debug statement that logs current route
const isApiAuthRoute = pathname.startsWith(apiAuthRoute);
const isPublicRoute = publicRoutes.includes(pathname);
const isProtectedRoute = protectedRoutes.includes(pathname);
if(isApiAuthRoute) {
// Allow access to /api/auth routes
return;
}
if(isProtectedRoute) {
// Check if the user is signed in
const session = await getSession({ req });
if(!session) {
return Response.redirect(new URL(DEFAULT_SIGNIN_REDIRECT, req.nextUrl));
}
return;
}
});
Let's wrap it middleware by redirecting when not signed-in and on a public route
feat: Add route authorization logic to middleware feat(auth): Handle public routes in middleware
import { NextRequest } from "next/server";
import NextAuth from "next-auth";
import authConfig from "@/auth.config";
import {
apiAuthRoute,
DEFAULT_SIGNIN_REDIRECT,
protectedRoutes,
publicRoutes
} from "@/routes";
import { getSession } from "next-auth/react";
const { auth } = NextAuth(authConfig);
export default auth(async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl; // Destructure the pathname from req.nextUrl
console.log("ROUTE: ", pathname); // Debug statement that logs current route
const isApiAuthRoute = pathname.startsWith(apiAuthRoute);
const isPublicRoute = publicRoutes.includes(pathname);
const isProtectedRoute = protectedRoutes.includes(pathname);
// Check if the user is signed in
const session = await getSession({ req });
if(isApiAuthRoute) {
// Allow access to /api/auth routes
return;
}
if(isProtectedRoute) {
if(!session) {
return Response.redirect(new URL(DEFAULT_SIGNIN_REDIRECT, req.nextUrl));
}
return;
}
// If not signed-in and not on a public route, then redirect
if (!session && !isPublicRoute) {
return Response.redirect(new URL(DEFAULT_SIGNIN_REDIRECT, req.nextUrl));
}
// Allow every other route
return;
});
const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
fix(middleware): Remove getSession reliance
import { NextRequest, NextResponse } from "next/server";
import NextAuth from "next-auth";
import authConfig from "@/auth.config";
import {
apiAuthRoute,
DEFAULT_SIGNIN_REDIRECT,
protectedRoutes,
publicRoutes
} from "@/routes";
// Use only one of the two middleware options below
// 1. Use middleware directly
// export const { auth: middleware } = NextAuth(authConfig)
// 2. Wrapped middleware option
const { auth } = NextAuth(authConfig);
export default auth(async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
console.log("ROUTE: ", pathname);
const isApiAuthRoute = pathname.startsWith(apiAuthRoute);
const isPublicRoute = publicRoutes.includes(pathname);
const isProtectedRoute = protectedRoutes.includes(pathname);
if (isApiAuthRoute || isPublicRoute) {
// Allow access to /api/auth routes and public routes
return NextResponse.next();
}
if (isProtectedRoute) {
// Redirect to sign-in page if not authenticated
return NextResponse.redirect(DEFAULT_SIGNIN_REDIRECT);
}
// Allow every other route
return NextResponse.next();
});
const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
Following the steps found in the authjs prisma edge compatibility split config solution, the bottom note states:
It is important to note here that we've now removed database functionality and support from next-auth
in the middleware. That means that we won't be able to fetch the session or other info like the user's account details, etc. while executing code in middleware. That means you'll want to rely on checks like the one demonstrated above in the /app/protected/page.tsx
file to ensure you're protecting your routes effectively. Middleware is then still used for bumping the session cookie's expiry time, for example.
feat(middleware): Remove database functionality
- Removed reliance on database functionality in middleware to support Edge Runtime.
- Middleware no longer fetches session or user details.
- Implemented route protection checks instead.
import NextAuth from "next-auth";
import authConfig from "@/auth.config";
// 1. Use middleware directly
export const { auth: middleware } = NextAuth(authConfig)
const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
refactor: Decouple middleware from database
- Removed database functionality and session fetching from middleware.
- Implemented route protection checks to ensure secure access.
- Enhanced compatibility with Edge Runtime by eliminating Node.js API dependencies.
The middleware change we're implementing can be considered a form of decoupling. By removing the reliance on database functionality and session fetching within the middleware, we're reducing the direct dependencies between the middleware and the database. This makes the middleware more modular and adaptable, especially for environments like the Edge Runtime where certain Node.js APIs aren't supported.
Decoupling in this context helps to isolate concerns, allowing the middleware to focus on route protection and session management without being tightly coupled to the database operations. This can lead to a more maintainable and scalable codebase.
After importing the primary auth.ts
config and use next-auth
we need to add route protection checks.
Let's use the routes.ts
file to find which routes we should be protecting:
routes.ts
/**
* An array of protected routes that require authentication.
* @type {string[]}
*/
export const protectedRoutes: string[] = [
"/auth/signin",
"/auth/signup",
];
refactor: Move resource protection to pages
Moved resource protection functionality from middleware to individual pages.
Now let's protect these routes manually with route protection checks.
- Import
auth
from@/auth
- Convert component to
async
- Get
session
byawait auth()
- Add route protection check and render not authenticated if
session
is `false
feat: Add route protection check for SignInPage
app\auth\signin\page.tsx
import React from 'react';
import { auth } from "@/auth"
import SignInForm from '@/components/auth/SignInForm';
export default async function SignInPage() {
const session = await auth()
if (!session) {
return <div>Not authenticated</div>
}
return (
<SignInForm />
);
}
feat: Add route protection check for SignUpPage
import React from 'react';
import { auth } from '@/auth';
import SignUpForm from '@/components/auth/SignUpForm';
export default async function SignUpPage() {
const session = await auth();
if (!session) {
return <div>Not authenticated</div>
}
return (
<SignUpForm />
)
}
Now these are just examples, the behavior we actually want for the SignInPage
and SignUpPage
is actually different. Let's handle that in the next section.
So far we can check if the user is already logged in through the session
variable we get from auth()
. The correct behavior is to redirect already authenticated users away from the signup/signin pages to a fallback URL: DEFAULT_LOGIN_REDIRECT
or just /settings
as specified in the routes.ts
file.
feat: Create reusable Redirect component
Added a reusable Redirect component that handles client-side redirection using the useRouter hook from Next.js. The component accepts a 'to' prop for the target URL, making it versatile for various redirection needs.
"use client";
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
// Define the Redirect component, which takes a 'to' prop for the URL to redirect to
const Redirect = ({ to }: { to: string }) => {
const router = useRouter();
useEffect(() => {
// This effect runs once when the component mounts
router.push(to); // Navigate to the specified URL
}, [router, to]); // Dependencies array ensures the effect runs only when 'router' or 'to' changes
return null; // Render nothing as this component is only for redirection
};
export default Redirect;
The useEffect
hook is used in the Redirect
component to perform side effects, such as navigating to a different URL, after the component has rendered. Here's why it's necessary:
-
Client-Side Navigation: The
useRouter
hook from Next.js is used for client-side navigation. Therouter.push(to)
method needs to be called after the component mounts to ensure the navigation happens on the client side. -
Avoiding Infinite Loops: By placing the navigation logic inside
useEffect
, we ensure that the redirection happens only once when the component mounts. If we putrouter.push(to)
directly in the component body, it would cause an infinite loop of re-renders.
Here's a quick breakdown of how useEffect
works in this context:
- Mounting: When the
Redirect
component mounts,useEffect
runs the provided function. - Dependency Array: The empty dependency array
[]
ensures that the effect runs only once, similar tocomponentDidMount
in class components. - Navigation: Inside the effect,
router.push(to)
is called to navigate to the specified URL.
This ensures that the redirection logic is executed correctly and efficiently.
feat(signup): Render Redirect on session existence
Implemented logic to render the Redirect component in SignUpPage when a session exists. This ensures users are redirected to the appropriate page if they are already signed in.
import React from 'react';
import { auth } from '@/auth';
import { DEFAULT_SIGNIN_REDIRECT } from '@/routes';
import SignUpForm from '@/components/auth/SignUpForm';
import Redirect from '@/components/auth/Redirect';
export default async function SignUpPage() {
const session = await auth();
if (session) {
return <Redirect to={DEFAULT_SIGNIN_REDIRECT} />;
}
return (
<SignUpForm />
)
}
feat(signin): Render Redirect on session existence
Implemented logic to render the Redirect component in SignInPage when a session exists. This ensures users are redirected to the appropriate page if they are already signed in.
import React from 'react';
import { auth } from '@/auth';
import { DEFAULT_SIGNIN_REDIRECT } from '@/routes';
import SignUpForm from '@/components/auth/SignUpForm';
import Redirect from '@/components/auth/Redirect';
export default async function SignUpPage() {
const session = await auth();
if (session) {
return <Redirect to={DEFAULT_SIGNIN_REDIRECT} />;
}
return (
<SignUpForm />
)
}
Now in the SignUpPage
, import the Redirect
and render the component if session
is truthy.
refactor(SignUpPage): dynamic Redirect rendering
Dynamically import and render the Redirect component in SignUpPage to handle client-side redirection. This ensures compatibility with server-side rendering and improves performance.
import React from 'react';
import { auth } from '@/auth';
import { DEFAULT_SIGNIN_REDIRECT } from '@/routes';
import SignUpForm from '@/components/auth/SignUpForm';
import dynamic from 'next/dynamic';
// Dynamically import the client component
const Redirect = dynamic(() => import('@/components/auth/Redirect'), { ssr: false });
export default async function SignUpPage() {
const session = await auth();
if (session) {
return <Redirect to={DEFAULT_SIGNIN_REDIRECT} />;
}
return (
<SignUpForm />
)
}
We dynamically import the client component in SignUpPage
to ensure that the redirection logic, which relies on client-side navigation, is only executed on the client side. Here are the key reasons for this approach:
-
Server-Side Rendering (SSR) Compatibility:
SignUpPage
is a server component, meaning it is rendered on the server. Server components do not have access to client-side features like theuseRouter
hook from Next.js.- By dynamically importing the client component with
{ ssr: false }
, we ensure that the client-side code (likeuseRouter
) is only executed in the browser, not on the server.
-
Avoiding SSR Issues:
- If we tried to use
useRouter
directly in a server component, it would cause errors becauseuseRouter
is designed to work only in the browser environment. - Dynamic import with
ssr: false
prevents these issues by ensuring the client component is only rendered on the client side.
- If we tried to use
-
Performance Optimization:
- Dynamic imports can help with performance by splitting the code and loading the client component only when needed. This can reduce the initial load time of the server-rendered page.
refactor(SignInPage): dynamic Redirect rendering
Dynamically import and render the Redirect component in SignInPage to handle client-side redirection. This ensures compatibility with server-side rendering and improves performance.
import React from 'react';
import { auth } from "@/auth"
import dynamic from 'next/dynamic';
// Dynamically import the client component
const Redirect = dynamic(() => import('@/components/auth/Redirect'), { ssr: false });
import { DEFAULT_SIGNIN_REDIRECT } from '@/routes';
import SignInForm from '@/components/auth/SignInForm';
export default async function SignInPage() {
const session = await auth()
if (session) {
return <Redirect to={DEFAULT_SIGNIN_REDIRECT} />;
}
return (
<SignInForm />
);
}
docs: Add explanation of dynamic and lazy loading
Added documentation to explain the concepts of dynamic and lazy loading. This includes the benefits of code splitting, client-side rendering, and conditional loading. Examples are provided to illustrate how to implement these techniques using Next.js.
The dynamic
function from next/dynamic
in Next.js is used to dynamically import components. This means that the component is only loaded when it's needed, rather than at the initial page load. This can improve performance by reducing the initial bundle size and speeding up the initial load time of your application.
-
Code Splitting:
- Dynamic imports enable code splitting, which divides your code into smaller chunks. These chunks are loaded on demand, reducing the initial load time and improving performance².
-
Client-Side Rendering:
- You can specify that a component should only be rendered on the client side by setting
ssr: false
. This is useful for components that rely on browser-specific APIs or need to interact with the DOM¹.
- You can specify that a component should only be rendered on the client side by setting
-
Conditional Loading:
- Components can be loaded conditionally based on user interactions or other criteria. This helps in deferring the loading of components until they are actually needed¹.
Here's an example of how to use dynamic
to import a component:
import dynamic from 'next/dynamic';
// Dynamically import the component
const DynamicComponent = dynamic(() => import('../components/MyComponent'), { ssr: false });
export default function MyPage() {
return (
<div>
<h1>My Page</h1>
<DynamicComponent />
</div>
);
}
- Importing
dynamic
: First, you import thedynamic
function fromnext/dynamic
. - Dynamic Import: Use
dynamic
to import the component. The function takes a callback that returns the import statement for the component. - SSR Option: The
{ ssr: false }
option ensures that the component is only rendered on the client side, not during server-side rendering. - Usage: You can then use the dynamically imported component just like any other React component.
- Large Components: For components that are large and not needed immediately.
- Client-Side Only: For components that rely on client-side APIs or need to interact with the DOM.
- Conditional Rendering: When you want to load components based on user interactions or other conditions.
Dynamic imports are a powerful feature in Next.js that can help optimize your application's performance and user experience.
From Mozilla:
Lazy loading is a strategy to identify resources as non-blocking (non-critical) and load these only when needed. It's a way to shorten the length of the critical rendering path, which translates into reduced page load times.
Lazy loading can occur on different moments in the application, but it typically happens on some user interactions such as scrolling and navigation.
From Next.js:
Lazy loading in Next.js helps improve the initial loading performance of an application by decreasing the amount of JavaScript needed to render a route.
It allows you to defer loading of Client Components and imported libraries, and only include them in the client bundle when they're needed. For example, you might want to defer loading a modal until a user clicks to open it.
There are two ways you can implement lazy loading in Next.js:
- Using Dynamic Imports with
next/dynamic
- Using React.lazy() with Suspense
By default, Server Components are automatically code split, and you can use streaming to progressively send pieces of UI from the server to the client. Lazy loading applies to Client Components.
next/dynamic
is a composite of React.lazy() and Suspense. It behaves the same way in the app and pages directories to allow for incremental migration.
We'll walk through the documentation below, but to see our implementation of Credentials provider with a split-config skip to that section.
To setup Auth.js with external authentication mechanisms or simply use username and password, we need to use the Credentials
provider. This provider is designed to forward any credentials inserted into the login form (.i.e username/password) to your authentication service via the authorize callback on the provider configuration.
First, let's initialize the Credentials
provider in the Auth.js configuration file. You'll have to import the provider and add it to your providers
array.
./auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
// Your own logic for dealing with plaintext password strings; be careful!
import { saltAndHashPassword } from "@/utils/password"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
// You can specify which fields should be submitted, by adding keys to the `credentials` object.
// e.g. domain, username, password, 2FA token, etc.
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
let user = null
// logic to salt and hash password
const pwHash = saltAndHashPassword(credentials.password)
// logic to verify if the user exists
user = await getUserFromDb(credentials.email, pwHash)
if (!user) {
// No user found, so this is their first attempt to login
// meaning this is also the place you could do registration
throw new Error("User not found.")
}
// return user object with their profile data
return user
},
}),
],
})
If you're using TypeScript, you can augment the User
interface to match the response of your authorize callback, so whenever you read the user in other callbacks (like the jwt
) the type will match correctly.
Finally, let's create a simple sign-in form.
./components/sign-in.tsx
import { signIn } from "@/auth"
export function SignIn() {
return (
<form
action={async (formData) => {
"use server"
await signIn("credentials", formData)
}}
>
<label>
Email
<input name="email" type="email" />
</label>
<label>
Password
<input name="password" type="password" />
</label>
<button>Sign In</button>
</form>
)
}
To improve the security of your Credentials provider use, we can leverage a run-time schema validation library like Zod to validate that the inputs match what we expect.
npm install zod
Next, we'll setup the schema and parsing in our auth.ts
configuration file, using the authorize
callback on the Credentials
provider.
./lib/zod.ts
import { object, string } from "zod"
export const signInSchema = object({
email: string({ required_error: "Email is required" })
.min(1, "Email is required")
.email("Invalid email"),
password: string({ required_error: "Password is required" })
.min(1, "Password is required")
.min(8, "Password must be more than 8 characters")
.max(32, "Password must be less than 32 characters"),
})
./auth.ts
import NextAuth from "next-auth"
import { ZodError } from "zod"
import Credentials from "next-auth/providers/credentials"
import { signInSchema } from "./lib/zod"
// Your own logic for dealing with plaintext password strings; be careful!
import { saltAndHashPassword } from "@/utils/password"
import { getUserFromDb } from "@/utils/db"
export const { handlers, auth } = NextAuth({
providers: [
Credentials({
// You can specify which fields should be submitted, by adding keys to the `credentials` object.
// e.g. domain, username, password, 2FA token, etc.
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
try {
let user = null
const { email, password } = await signInSchema.parseAsync(credentials)
// logic to salt and hash password
const pwHash = saltAndHashPassword(password)
// logic to verify if the user exists
user = await getUserFromDb(email, pwHash)
if (!user) {
throw new Error("User not found.")
}
// return JSON object with the user data
return user
} catch (error) {
if (error instanceof ZodError) {
// Return `null` to indicate that the credentials are invalid
return null
}
}
},
}),
],
})
Note: The industry has come a long way since usernames and passwords were first introduced as the go-to mechanism for authenticating and authorizing users to web applications. Therefore, if possible, we recommend a more modern and secure authentication mechanism such as any of the OAuth providers, Email Magic Links, or WebAuthn (Passkeys) options instead of username / password.
However, we also want to be flexible and support anything you deem appropriate for your application and use-case.
Note: The Credentials provider only supports the JWT session strategy. You can still create and save a database session and reference it from the JWT via an id, but you'll need to provide that logic yourself.
First, let's initialize the Credentials
provider in the Auth.js configuration file. You'll have to import the provider and add it to your providers
array.
feat: Add credentials provider in auth.config.ts
- Imported CredentialsProvider from next-auth/providers/credentials
- Added CredentialsProvider to the providers array
- Configured the authorize function to validate user credentials
./auth.config.ts
import type { NextAuthConfig } from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
// Notice this is only an object, not a full Auth.js instance
export default {
providers: [
GitHub,
Credentials({
// You can specify which fields should be submitted, by adding keys to the `credentials` object.
// e.g. domain, username, password, 2FA token, etc.
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
let user = null;
// logic to salt and hash password
const pwHash = saltAndHashPassword(credentials.password);
// logic to verify if the user exists
user = await getUserFromDb(credentials.email, pwHash);
if (!user) {
// No user found, so this is their first attempt to login
// meaning this is also the place you could do registration
throw new Error("User not found.");
}
// return user object with their profile data
return user;
},
}),
],
} satisfies NextAuthConfig;
Notice that the SignInSchema
we defined in /schemas/index.ts
is used in our server action: /actions/signIn.ts
.
But note that some users may not even use the sign-in screen to invoke the signIn
server action. They can manually send information to the /app/api/auth
. Because of this possibility we need to add the schema check in the providers array.
Summary: Adding the SignInSchema
check in the providers array ensures validation, even for users who bypass the sign-in screen and directly interact with the /app/api/auth
endpoint.
feat: Use SignInSchema to validate in auth config
import { SignInSchema } from "@/schemas";
export default {
providers: [
GitHub,
Credentials({
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
// Validate the credentials using the SignInSchema
const parsedValues = SignInSchema.safeParse(credentials);
The authorize
function validates the credentials using the SignInSchema
and performs the necessary authentication checks.
The Credentials provider is designed to forward any credentials inserted into the login form (.i.e username/password) to your authentication service via the authorize callback on the provider configuration.
Let's rewrite the authorize
function to reflect this change. We want to validate the fields using the SignInSchema
. We did this before in actions/signIn.ts
with the code: const parsedValues = SignInSchema.safeParse(values);
, but this time we don't pass in values
we pass in credentials
.
feat: Add authorize function w/ schema validation
- Added SignInSchema validation to the authorize function
- Retrieved user by email from the database
- Checked if the user exists and has a password
- Compared provided password with stored hashed password
- Returned user object if authentication is successful
auth.config.ts
import type { NextAuthConfig } from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcrypt";
import { SignInSchema } from "@/schemas";
import getUserByEmail from "@/utils/getUserByEmail";
export default {
providers: [
GitHub,
Credentials({
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
// Validate the credentials using the SignInSchema
const parsedValues = SignInSchema.safeParse(credentials);
if (parsedValues.success) {
const { email, password } = parsedValues.data;
// Retrieve the user by email from the database
const user = await getUserByEmail(email);
// Check if the user exists and has a password (not an OAuth user)
if (!user || !user.password) {
// If no user or user password (user may have signed-in with OAuth Google or GitHub)
// Credentials provider will not work without the user's hashed password
return null;
}
const passwordsMatch = await bcrypt.compare(
password, // The password credential input
user.password, // The password hash from our database
);
if (passwordsMatch) {
// Return user object with their profile data
return user;
}
}
// Return null if validation or authentication fails
return null;
},
}),
],
} satisfies NextAuthConfig;
The credentials
object in the Credentials
provider configuration is used to define the fields that users need to submit when signing in. It specifies the labels, types, and placeholders for these fields. While it's not strictly necessary, it helps to provide a clear structure for the expected input.
If you remove the credentials
object, the provider will still work, but users won't have the guidance on what fields to fill in.
However, it's generally a good practice to include the credentials
object to make the expected input clear and user-friendly.
We can check the credentials
object should contain by looking at /components/auth/SignInForm.tsx
.
To create the credentials
object for the sign-in form, we define the fields that users need to submit.
-
Define the
credentials
object:- Specify the fields
email
andpassword
with appropriate labels and types.
- Specify the fields
-
Integrate the
credentials
object into your NextAuth configuration.
feat: Define credentials object for validation
- Added email and password fields to credentials object
- Provides clear structure for user input in sign-in form
- Ensures proper validation and user guidance
auth.config.ts
import type { NextAuthConfig } from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcrypt";
import { SignInSchema } from "@/schemas";
import getUserByEmail from "./utils/getUserByEmail";
export default {
providers: [
GitHub,
Credentials({
credentials: {
email: { label: "Email", type: "email", placeholder: "Enter your email address" },
password: { label: "Password", type: "password", placeholder: "**************" },
},
authorize: async (credentials) => {
// ... authorize logic
},
}),
],
} satisfies NextAuthConfig;
In this configuration:
- The
credentials
object defines theemail
andpassword
fields with labels, types, and placeholders. - The
authorize
function validates the credentials using theSignInSchema
and performs the necessary authentication checks.
With our auth.config.ts
complete, we can now look in auth.ts
and see what functions we have:
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter"
import authConfig from "@/auth.config";
import prisma from "@/db/prismaSingleton";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
...authConfig,
});
The signIn
function in auth.js
is typically used to handle user authentication. It is part of the NextAuth.js library, which provides a flexible and easy-to-use authentication solution for Next.js applications. The signIn
function can be used to authenticate users with various providers, such as credentials, OAuth providers (like Google, GitHub), and more.
Here's a brief overview of how the signIn
function works:
-
Provider-Based Authentication: You can pass a provider to the
signIn
function to authenticate directly with that provider. For example,signIn("github")
will initiate the sign-in process with GitHub. -
Credentials Authentication: When using credentials, you can pass the user's email and password to the
signIn
function. This is useful for custom authentication systems where you handle user credentials directly. -
Redirection: The
signIn
function can also handle redirection after successful authentication. You can specify aredirectTo
URL to redirect users to a specific page after they sign in.
Here's an example of how you might use the signIn
function with credentials:
import { signIn } from "next-auth/react";
async function handleSignIn(email, password) {
try {
const result = await signIn("credentials", {
email,
password,
redirectTo: "/dashboard",
});
if (result.error) {
console.error("Sign-in error:", result.error);
} else {
console.log("Sign-in successful!");
}
} catch (error) {
console.error("Sign-in failed:", error);
}
}
This function attempts to sign in a user with their email and password, and redirects them to the /dashboard
page upon successful authentication.
Let's improve the error handling and logging first:
feat(signIn): Improve error handling and logging
- Added detailed error logging for validation errors.
- Improved user-friendly error messages.
- Logged received values with descriptive messages for better debugging.
This update ensures better traceability and easier debugging of sign-in issues.
actions/signIn.ts
"use server";
import { z } from "zod";
import { SignInSchema } from "@/schemas";
/**
* Validates user sign-in data using the provided schema.
*
* @param values - User input data to validate.
* @returns An object with either a success message or an error message.
*/
export default async function signIn(values: z.infer<typeof SignInSchema>) {
console.log("Received values:", values);
const parsedValues = SignInSchema.safeParse(values);
if (!parsedValues.success) {
console.error("Validation errors:", parsedValues.error.errors);
return {
error: "Invalid fields! Please check your input.",
};
}
return {
success: "Sign in successful!",
};
}
Now we import signIn
and rename it to authSignIn
to avoid naming conflict with are signIn
server action. We destructure the email and password from parsedValues
. Then in a try..catch
, call authSignIn
with the credentials.
feat(signIn): Invoke authSignIn with credentials
- Destructured email and password from parsedValues.data
- Invoked authSignIn with credentials in a try-catch block
- Added error handling for sign-in process
"use server";
import { z } from "zod";
import { signIn as authSignIn } from "@/auth";
import { SignInSchema } from "@/schemas";
/**
* Validates user sign-in data using the provided schema.
*
* @param values - User input data to validate.
* @returns An object with either a success message or an error message.
*/
export default async function signIn(values: z.infer<typeof SignInSchema>) {
console.log("Received values:", values);
const parsedValues = SignInSchema.safeParse(values);
if (!parsedValues.success) {
console.error("Validation errors:", parsedValues.error.errors);
return {
error: "Invalid fields! Please check your input.",
};
}
const { email, password } = parsedValues.data;
try {
await authSignIn("credentials", {
email,
password,
});
} catch (error) {
console.error(error);
}
return {
success: "Sign in successful!",
};
}
The signIn
function will also handle redirection after successful authentication. You can specify a redirectTo
URL to redirect users to a specific page after they sign in.
For this we will use our global variable DEFAULT_SIGNIN_REDIRECT
to redirect to. We will have additional redirection logic later (using callbacks), but for now let's set add redirectTo
to the authSignIn
in the signIn
server action:
feat: Add redirection to signIn server action
- Implemented redirection to a specified URL after successful sign-in.
import { DEFAULT_SIGNIN_REDIRECT } from "@/routes";
export default async function signIn(values: z.infer<typeof SignInSchema>) {
console.log("Received values:", values);
const parsedValues = SignInSchema.safeParse(values);
if (!parsedValues.success) {
console.error("Validation errors:", parsedValues.error.errors);
return {
error: "Invalid fields! Please check your input.",
};
}
const { email, password } = parsedValues.data;
try {
await authSignIn("credentials", {
email,
password,
redirectTo: DEFAULT_SIGNIN_REDIRECT,
});
} catch (error) {
console.error(error);
}
return {
success: "Sign in successful!",
};
}
feat: Improve error handling in signIn action
- Enhanced error handling by using AuthError for specific error types.
- Added detailed logging for sign-in attempts, excluding sensitive information.
- Removed logging of the entire values object to ensure security.
- Standardized error messages for better user experience.
- Ensured type safety and consistency in error handling.
"use server";
import { z } from "zod";
import { signIn as authSignIn } from "@/auth";
import { DEFAULT_SIGNIN_REDIRECT } from "@/routes";
import { SignInSchema } from "@/schemas";
import { AuthError } from "next-auth";
/**
* Validates user sign-in data using the provided schema.
*
* @param values - User input data to validate.
* @returns An object with either a success message or an error message.
*/
export default async function signIn(values: z.infer<typeof SignInSchema>) {
const parsedValues = SignInSchema.safeParse(values);
if (!parsedValues.success) {
console.error("Validation errors:", parsedValues.error.errors);
return {
error: "Invalid fields! Please check your input.",
};
}
const { email, password } = parsedValues.data;
try {
console.log(`Attempting sign-in for email: ${email}`);
await authSignIn("credentials", {
email,
password,
redirectTo: DEFAULT_SIGNIN_REDIRECT,
});
} catch (error) {
console.error("Sign-in error:", error);
if (error instanceof AuthError) {
switch (error.type) {
case "CredentialsSignin":
return { error: "Invalid credentials! Please try again." };
default:
return { error: "An unexpected error occurred. Please try again later." };
}
}
// Throw error to redirect as we are using authSignIn inside of server action
throw error;
}
return {
success: "Sign in successful!",
};
}
Let's use the signOut
from auth.ts
and rename it to authSignOut
. Let's put it inside the SettingsPage
to be able to clear our signin/credentials cache.
feat: Implement sign-out functionality in settings
- Add a form to the SettingsPage component
- Implement server-side action for sign-out functionality
- Rename signOut to authSignOut to avoid naming conflicts
- Display session information within the page
app\(protected)\settings\page.tsx
import React from 'react';
import { auth, signOut as authSignOut } from '@/auth';
export default async function SettingsPage() {
const session = await auth();
return (
<div>
{JSON.stringify(session)}
<form action={async () => {
"use server";
await authSignOut();
}}>
<button type="submit">
Sign Out
</button>
</form>
</div>
)
}
-
Navigate to the Registration Page
- Go to
localhost:3000/auth/signup
.
- Go to
-
Register a New Account
- Enter your username, email, and password.
- Submit the form to create your account.
-
Navigate to the Sign-In Page
- Click on "Already have an account?" or go directly to
localhost:3000/auth/signin
.
- Click on "Already have an account?" or go directly to
-
Sign In
- Enter your credentials to sign in.
- Upon successful sign-in, you should be redirected to the settings page where you will see your session details displayed as a JSON string and a sign-out button.
-
Sign Out
- Click the "Sign Out" button.
- Confirm that you are redirected back to the sign-in page.
Auth.js libraries only expose a subset of the user's information by default in a session to not accidentally expose sensitive user information. This is name
, email
, and image
.
A common use case is to add the user's id to the session. Below it is shown how to do this based on the session strategy you are using.
To have access to the user id, add the following to your Auth.js configuration:
auth.ts
providers,
callbacks: {
jwt({ token, user }) {
if (user) { // User is available during sign-in
token.id = user.id
}
return token
},
session({ session, token }) {
session.user.id = token.id
return session
},
},
}
During sign-in, the jwt
callback exposes the user's profile information coming from the provider. You can leverage this to add the user's id to the JWT token. Then, on subsequent calls of this API will have access to the user's id via token.id
. Then, to expose the user's id in the actual session, you can access token.id
in the session callback and save it on session.user.id
.
Calls to auth()
or useSession()
will now have access to the user's id.
To extend the session with JWT in a split configuration setup, you'll need to modify both the auth.ts
and auth.config.ts
files. Here's how you can do it:
In this file, you define the callbacks for JWT and session handling. Add the necessary callbacks to extend the session with JWT.
import { NextAuthOptions } from 'next-auth';
import Providers from 'next-auth/providers';
const options: NextAuthOptions = {
providers: [
// Add your providers here
],
callbacks: {
async jwt({ token, user }) {
// Initial sign in
if (user) {
token.id = user.id;
token.email = user.email;
}
return token;
},
async session({ session, token }) {
// Add token properties to the session
if (token) {
session.user.id = token.id;
session.user.email = token.email;
}
return session;
},
},
};
export default options;
In this file, you import the configuration and initialize the authentication.
import NextAuth from 'next-auth';
import authOptions from './auth.config';
export default NextAuth(authOptions);
- JWT Callback: This callback is called whenever a JWT is created or updated. You can add custom properties to the token here.
- Session Callback: This callback is called whenever a session is checked. You can add custom properties from the token to the session here.
By adding these callbacks, you ensure that the JWT is extended with the necessary properties and that these properties are available in the session.
feat(auth): Create custom JWT and Session types
- Defined CustomJWT and CustomSession interfaces to extend JWT and session objects
- Updated auth.config.ts to use the custom types in JWT and session callbacks
- Added type guards to ensure proper type assignments
This commit ensures that JWT and session objects include necessary user properties, improving type safety and consistency.
import { JWT } from 'next-auth/jwt';
import { Session } from 'next-auth';
export interface CustomJWT extends JWT {
id: string;
email: string;
}
export interface CustomSession extends Session {
user: {
id: string;
email: string;
};
}
Let's add a console statement inside async session()
and log the two parameters: session
and token
as sessionToken
.
auth.config.ts
callbacks: {
async jwt({ token, user }) {
const customToken = token as CustomJWT;
// Initial sign in
if (user) {
if (user.id && user.email) {
customToken.id = user.id;
customToken.email = user.email;
}
}
return customToken;
},
async session({ session, token }) {
const customSession = session as CustomSession;
const customToken = token as CustomJWT;
console.log({
session,
sessionToken: token,
})
// Add token properties to the session
if (customToken) {
customSession.user.id = customToken.id;
customSession.user.email = customToken.email;
}
return customSession;
},
},
We will get in the console something like this:
{
sessionToken: {
name: 'new',
email: 'new@email.com',
picture: null,
sub: 'clqo81sxj000ga7tanqbvv1x',
iat: 1703787810,
exp: 1706379810,
jti: 'ff4eaf6f-6e09-40df-ad248-df0007cac76f',
},
session: {
user: { name: 'new', email: 'new@email.com', image: null }
},
}
We can determine the ID for the current user is contained in a property called sub
from the sessionToken
.
So what we can do is that if we have both the token.sub
and session.user
we can assign the session.user.id
to the token.sub
, this will extend our session object.
feat: Extend session object with current user ID
async session({ session, token }) {
const customSession = session as CustomSession;
const customToken = token as CustomJWT;
// Add token properties to the session
if (customToken) {
customSession.user.id = customToken.id;
customSession.user.email = customToken.email;
}
// Assign the session.sub field (which is the ID) to the session.id
if (token.sub && session.user) {
session.user.id = token.sub;
}
return customSession;
},
Now we know how to extend session object with ID, so anywhere where we can access the session the user ID is available to us.
Next we want to be able to extend this behavior by adding custom fields to the session object. Let's create a custom field we would want to add to the session: user roles.
Inside the prisma schema add the a role
field to the User
model. It will be of type UserRole
which will be an enum containing: NONE, USER, ADMIN
.
feat: Add user role to Prisma schema
- Added UserRole enum with NONE, USER, and ADMIN roles
- Updated User model to include role field with default value USER
prisma\schema.prisma
enum UserRole {
NONE
USER
ADMIN
}
model User {
id String @id @default(cuid())
username String?
email String @unique
emailVerified DateTime?
image String?
password String?
role UserRole @default(USER)
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Before we do this, let's sign-out of our project. Then if our project is still running, close it with CTRL+C.
For a reminder let's see how our prisma work flow cycle should be (see Prisma Toolkit).
- To see our list of commands:
npx prisma --help
- Generate artifacts (e.g. Prisma Client):
npx prisma generate
- Reset the entire database (ONLY in development NOT in production)
npx prisma migrate reset
- Have an overview of our database (e.g., pgAdmin, prisma studio, neon database, etc.)
We should be able to see that no users are in the database anymore.
Auth.js libraries allow you to restrict users by intercepting the registration/login flow. You can use the signIn
callback to control whether a user is allowed to sign up or not.
All callbacks are async functions, so you can also get extra information from a database or external APIs.
For example, it is possible to only allow your company's employees to sign up with their company email addresses.
Add the following code to your Auth.js configuration:
callbacks: {
signIn({ profile }) {
return profile.email.endsWith("@yourdomain.com")
}
}
If the user's email does not end with @yourdomain.com
, the sign-up (in case of a database strategy) process will be blocked, and subsequent login attempts will be rejected as well.
feat: Restrict user access during sign-in flow
Using the signIn callback from auth.js to restrict users.
There are two ways to add role-based access control (RBAC) to your application with Auth.js, based on the session strategy you choose. Let's see an example for each of these.
Start by adding a profile()
callback to the providers' config to determine the user role:
./auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
export const { handlers, auth } = NextAuth({
providers: [
Google({
profile(profile) {
return { role: profile.role ?? "user", ... }
},
})
],
})
Warning: Determining the users role is your responsibility, you can either add your own logic or if your provider returns a role you can use that instead.
Persisting the role will be different depending on the session strategy you're using. If you don't know which session strategy you're using, then most likely you're using JWT (the default one).
When you don't have a database configured, the role will be persisted in a cookie, by using the jwt()
callback. On sign-in, the role
property is exposed from the profile
callback on the user
object. Persist the user.role
value by assigning it to token.role
. That's it!
If you also want to use the role on the client, you can expose it via the session
callback.
./auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
export const { handlers, auth } = NextAuth({
providers: [
Google({
profile(profile) {
return { role: profile.role ?? "user", ... }
},
})
],
callbacks: {
jwt({ token, user }) {
if(user) token.role = user.role
return token
},
session({ session, token }) {
session.user.role = token.role
return session
}
}
})
Note: With this strategy, if you want to update the role, the user needs to be forced to sign in again.
When you have a database, you can save the user role on the User model. The below example is showing you how to do this with Prisma, but the idea is the same for all adapters.
First, add a role
column to the User model.
/prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
role String? // New column
accounts Account[]
sessions Session[]
}
The profile()
callback's return value is used to create users in the database. That's it! Your newly created users will now have an assigned role.
If you also want to use the role on the client, you can expose it via the session
callback.
./auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import prisma from "lib/prisma"
export const { handlers, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
profile(profile) {
return { role: profile.role ?? "user", ... }
}
})
],
callbacks: {
session({ session, user }) {
session.user.role = user.role
return session
}
}
})
Note: It is up to you how you want to manage to update the roles, either through direct database access or building your role update API.