Creating the Signup Flow
Course: Next.js Authentication and Authorization
Introduction
Now that we’ve got our database set up with a User model, the next big step is letting people actually sign up for an account. This is the first piece of the authentication puzzle. The frontend portion is typical React forms stuff, so we'll focus on building the API route and auth helper functions to handle things like hashing the password and creating a new user in the database.
Routes in Next.js (App Router)
The App Router is the way Next.js organizes your entire app. Instead of having one giant src folder with everything mixed together, Next.js uses the app directory to structure both your pages and your backend API.
Here’s the big idea: your folder structure = your routes.
Every folder inside app can become a URL on your site. Every file called page.tsx
(or page.js
) becomes a webpage. Every file called layout.tsx
can wrap pages with a shared layout. Every file called route.ts
(or route.js
) becomes an API endpoint.
For example, this is how it might look:
app/
page.tsx
about/
page.tsx
api/
auth/
route.ts
Building the Signup API Route
Now that we understand how the App Router works, it’s time to actually create the backend endpoint that will handle our signup requests. In Next.js, we don’t need to spin up a separate server, we can put our backend logic directly inside the app/api folder.
Remember: any folder inside app/api with a route.ts file becomes an API endpoint.
So if we make a new folder like this:
app/
api/
auth/
signup/
route.ts
Then Next.js will automatically give us an endpoint at:
/api/auth/signup
This is where we’ll send the data from our signup form (like email and password). The route.ts
file works just like a mini backend: we can write functions for handling different HTTP methods (POST, GET, etc.), and Next.js will call them when a request comes in.
For signup, we’ll focus on the POST method, since the user is sending new data to our server (their account information).
Create POST Route
To get started, you can implement the following code (which will be explained below):
import { NextRequest, NextResponse } from "next/server"
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json(
{ error: "Email and password are required" },
{ status: 400 }
)
}
if (password.length < 6) {
return NextResponse.json(
{ error: "Password must be at least 6 characters long" },
{ status: 400 }
)
}
} catch (error) {
console.error("Signup error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
Firstly, we'll import tools Next.js uses for handling API requests and sending back responses. Then we define the route handler so we'll create a POST function which takes a request.
The request is parsed and we extract the email and password that is sent by the frontend. If the email or password does not exist, we'll send back an error response since we can't create an account with incomplete information.
If the email and password are present, we'll validate the password strength. This is a very basic check, you can implement more stringent checks for your own system, but this will work for the sake of this tutorial.
Finally we have error handling using a try/catch block. Basically, if anything goes wrong, the catch block will log the error and send an error response instead of crashing.
Create Signup Logic
The next step is to create the signup logic, like creating the session, hashing the password, and inserting the user into the database. Although this can be done directly in the API endpoint, I prefer to do it in a separate file. That way we can make our code reusable.
The API route will focus on validating requests, returning responses and act as a wrapper for our signup logic.
With that said, go back into the lib
directory and create an auth.ts
file. Then insert the following code into that file:
import { db } from "./db"
import { User } from "@prisma/client"
export async function signUp(
email: string,
password: string
): Promise<{ success: boolean; error?: string; user?: User }> {
try {
const existingUser = await db.user.findUnique({ where: { email } })
if (existingUser) return { success: false, error: "User already exists" }
const { salt, hash } = await hashPassword(password)
const userCount = await db.user.count() //optional
const role = userCount === 0 ? "admin" : "user" //optional
const user = await db.user.create({
data: {
email,
password: hash,
salt,
role, //optional
},
})
return { success: true, user }
} catch (error) {
console.error("Sign up error:", error)
return { success: false, error: "Failed to create user" }
}
}
Breaking it down, we define the signUp
function which takes in the email and password as the input. Since this is an async function, we return a Promise, which takes on the form of an object telling us if signup was a success or not, and if not, why (error). If it worked, it also gives us back the new user (by using optional chaining).
Again, everything is wrapped in a try/catch block. The first thing we do is check if a user with the same email already exists. If so, we'll return an error. Otherwise, we proceed to create the salt and hashed password for the user. We will go over this function in just a bit.
Optionally, you can include the userCount
logic so that the app bootstraps itself, making the first account created an admin.
We then create and store the new user in the database along with the salt. If everything works, we return success and the new user object.
Hashing the Password
Now we need to actually create the function that hashes our password. Within the same auth.ts
file, add the following code:
import crypto from "crypto"
import { promisify } from "util"
const scryptAsync = promisify(crypto.scrypt)
const KEY_LENGTH = 64
export type ScryptHash = {
salt: string
hash: string
}
export async function hashPassword(password: string): Promise<ScryptHash> {
const salt = crypto.randomBytes(16).toString("hex")
const derivedKey = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer
return { salt, hash: derivedKey.toString("hex") }
}
We declare a variable scryptAsync
since we'll be using it more than once, same with KEY_LENGTH
. crypto.scrypt
is a callback -based function, so we use promisify
to convert it into a function that returns a promise (basically turning it into a "Promise version"). The key length defines how long the final hash should be. 64 is common because it produces a strong, long enough key for secure storage.
We also define the type for TypeScript of our ScryptHash, which is an object with a salt and hash string.
Then we declare the function hashPassword
which takes a password and returns a Promise. Inside that function we create a 16 byte salt and convert it to string.
We then call the scrypt
algorithm using the password, salt, and key length to get a derived key. The derived key is a binary object, so we need to specify that by adding as Buffer
.
After that is done, the salt and hash is returned. Since the derived key is a binary object, it needs to be converted to a string to make reading and storing simpler.
Now we can go back into the route.ts
folder and import the signUp
function and insert it into our API route:
const result = await signUp(email, password)
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
We call the signUp
function and store it in a result. If the result was not successful, then we return an error.
At this point, our user is created. However, we haven't created a session for the user yet, meaning they would need to log in again. So to improve user experience, we'll create the session for them on account creation.
Create Session
Head back into the auth.ts
file and add the following:
import { getIronSession } from "iron-session"
export type SessionData = {
userId?: string
email?: string
role?: string
isLoggedIn: boolean
}
export const sessionOptions = {
password: process.env.SESSION_PASSWORD!,
cookieName: "auth-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
sameSite: "lax" as const,
maxAge: 60 * 60 * 24 * 7, // 7 days
},
}
export async function getSession() {
const cookieStore = await cookies()
const session = await getIronSession<SessionData>(cookieStore, sessionOptions)
if (!session.isLoggedIn) {
session.isLoggedIn = false
}
return session
}
First, we define the SessionData
type, which tells us what the session object will store. Then we need to define session options.
The password is a secret key that is used to encrypt and sign the cookie. It needs to be at least 32 characters long for security. To generate a key, run the following command in your terminal:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Then you will need to go into your .env
file and set the key.
The cookieName
defines the name of the cookie and is how the browser identifies which cookie belongs to your app's session.
In regards to the cookieOptions
, we set it to secure so that cookies are only sent over HTTPS in production. We set httpOnly
to true to prevent JavaScript on the client from reading the cookie (prevents against XSS attacks). sameSite
is set to lax to restrict cookie sending. maxAge
is set to 7 days, after that the user would need to log in again.
Inside of the getSession
function, we access the cookies that are sent by the client and store it in cookieStore
. This gets passed into getIronSession
along with sessionOptions
, which returns an object in the form of our SessionData
.
Then we check if isLoggedIn
exists, if not then we just set it to false in case it might be undefined.
Create Session After Signup
Now with the session logic in place, we can call it in our signup API route:
const session = await getSession()
session.userId = result.user.id
session.email = result.user.email!
session.role = result.user.role
session.isLoggedIn = true
await session.save()
return NextResponse.json({
success: true,
user: {
id: result.user.id,
email: result.user.email,
role: result.user.role,
},
})
We call getSession()
and store it in a session
variable. Then we add the user info to the session and save the session. This persists the updated session to the cookie that is sent back to the client.
A success response is then sent back to the client along with some user info.
With that complete, the user will be able to sign up and be logged in after signup! I know that was a lot, but the good news is we'll be able to reuse some of the functions we created in this section! The next lesson is to create the log in flow for the user.