NextJS Authentication boilerplate with: Neon, Postgress, Typescript, ArcicJS, Resend, Twilio and Shadcn Components
To get started, you need to create a .env
file in the root of the project. Add all the environment variables:
Below are the environment variables and where you can get them
DOMAIN="http://localhost:3000"
Get a DATABASE_URL
by following the following instructions: Neon Database URL.
DATABASE_URL=
Generate a JWT_SECRET
in linux or MAC by typing the following in your terminal openssl rand -base64 32
.
Alternatively, you can get one online in the following site: Generate random string for a JWT Secret
JWT_SECRET=
Create a RESEND_API_KEY
by following this link: Create Resend API key
RESEND_API_KEY=
Get a google GOOGLE_CLIENT_ID
and GOOGLE_SECRET
by following the following instructions: Get google client id and secret
GOOGLE_CLIENT_ID=
GOOGLE_SECRET=
Get the facebook FACEBOOK_APP_ID
and FACEBOOK_APP_SECRET
by following the following instructions: Get facebook app id and app secret
FACEBOOK_APP_ID=
FACEBOOK_APP_SECRET=
Get the twilio TWILIO_ACCOUNT_SID
and TWILIO_AUTHENTICATION_TOKEN
by following these instructions: Get twilio account side and authentication token
TWILIO_ACCOUNT_SID=
TWILIO_AUTHENTICATION_TOKEN=
Follow these instructions to create a TWILIO_2FA_SERVICE_ID
for the Verify API.
TWILIO_2FA_SERVICE_ID=
At the end of it all your .env
file should look like this
DOMAIN="http://localhost:3000"
DATABASE_URL=your_database_url
JWT_SECRET=your_jwt_secret
RESEND_API_KEY=your_resend_api
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_SECRET=your_google_secret
FACEBOOK_APP_ID=your_facebook_api_id
FACEBOOK_APP_SECRET=your_facebook_app_secret
TWILIO_ACCOUNT_SID=your_twilio_account_sid
TWILIO_AUTHENTICATION_TOKEN=your_twilio_autentication_token
TWILIO_2FA_SERVICE_ID=your_twilio_service_id_for_the_verify_service
#based on your package manager
npm install
#or
yarn install
#or
pnpm install
#or
bun install
#based on your package manager
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
Open http://localhost:3000 with your browser to see the result.
The sign up flow, works as follows:
- Once the user submits the data, we validate it: We use zod for validation.
const isValid = SignUpSchema.safeParse(data);
// return error if not valid
if (!isValid.success) return { error: 'The data provided is invalid!' };
- If the data doesn't have a code we add them to our DB and then we send them a code and return them to the sign up page for them to add the code to the form.
- Check if the user we are trying to add is in our DB
// check if the user already exists
const exisitingUser = await getUserByEmailOrTelephone({ email, telephone });
if (exisitingUser) {
if (email) return { error: 'Email already in use!' };
if (telephone) return { error: 'Phone number already in use!' };
}
- We add them to the DB
try {
// insert data to db
const fullName = `${firstName} ${lastName}`;
const hash = bcrypt.hashSync(password, 12);
await db.user.create({
data: { fullName, email, telephone, password: hash },
});
} catch (error) {
return {
error: 'An error occured on our side, please try signing up again',
};
}
- We send them the code
if (telephone) {
// send them a code if they used their phone
// send them to the sign up page with the data they had given us
}
if (email) {
// send them a code if they used their email
// send them to the sign up page with the data they had given us
}
- We check if the data includes a code for verifying their contact (phone or email) and if so we verify their contact before proceeding.
if (code) {
if (telephone) {
// verify the phone number code if they used their phone for verification
}
if (email) {
// verify the email code if they used their email for verification
}
}
The login flow works as follows:
- Once the user submits the data, we validate it using zod.
const isValid = LogInSchema.safeParse(data);
if (!isValid.success) return { error: 'The data provided in invalid!' };
- We look up the user, and if we do not have a user, we return an error
// look up the user
const existingUser = await getUserByEmailOrTelephone({ email, telephone });
// return error if we have no user in the DB
if (!existingUser)
return { error: 'Invalid username or password, try again!' };
-
We check if the user has been verified, if not we verify them. Here we repeat the process we went through in the sign up server action just in case they skipped the verification part.
-
We go through a similar process of verification but now, only if the user has 2FA enabled in their settings.
// check if the user has 2FA enabled
if (existingUser.isTwoFactorEnabled) {
// check if they have given us the code, so that we can verify it
// if they do not have a code, send it to them so that we can verify them
}
- Compare if passwords match
// compare if passwords match
const passwordsMatch = bcrypt.compareSync(
password,
existingUser.password || '',
);
// return error if passwords don't match
if (!passwordsMatch) return { error: 'Invalid email or password' };
- Create a session once the user is authenticated
// add the tokens to the HTTP-only cookie
await createSession({
userId: existingUser.id,
userRole: existingUser.role,
_v: existingUser.refreshTokenVersion,
});
- We return the user we just created to the frontend so that they can be persisted in the state
- Arctic abstracts some of the processes for us, but in a very simple way so that we can still tell what's going on.
- First, we start by creating a url that we will redirect the user to in the oAuth provider. In google that is as shown below. It's also good to note that google oAuth has PKCE (pronounced pixy), which is an extra layer of security. We won't be dwelling on that a lot.
const url = await google.createAuthorizationURL(state, codeVerifier, { scopes: ['email', 'profile'], //open id connect is already added in this scope by Arctic });
- We then redirect the user to that URL and the user get's to choose whether they want to give our app that access to their data. If they choose YES, we continue:
// store state verifier as cookie
cookies().set('state', state, {
secure: process.env.NODE_ENV === 'production',
path: '/',
httpOnly: true,
maxAge: 10 * 60, // 10 minutes
});
// store code verifier as cookie
cookies().set('code_verifier', codeVerifier, {
secure: process.env.NODE_ENV === 'production',
path: '/',
httpOnly: true,
maxAge: 10 * 60, // 10 minutes
});
return NextResponse.redirect(url);
-
At this point, the user is in the google website, accepting to give us these rights, after which they are sent back to our site with a positive response or a negative response based on their willingness to give our site the data. They are redirected to the callback url we gave google in this case
/api/auth/callback/google/route.ts
in the/app
directory. -
We create a GET route in the callback URL that google will call
export const GET = async (req: NextRequest) => {
};
- We then go ahead and validate the code and state we sent to google to ensure they are the ones we sent:
const searchParams = req.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const storedState = cookies().get('state')?.value ?? null;
const codeVerifier = cookies().get('code_verifier')?.value ?? null;
const Location = '/dashboard';
if (
!code ||
!state ||
!storedState ||
!codeVerifier ||
state !== storedState
) {
return new Response(null, {
status: 302,
headers: {
Location: `/login?error=${'Invalid credentials'}`,
},
});
}
try {
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
// decode the id token and get the data
const googleUser = decode(tokens.idToken);
- We then go ahead and authenticate the user by creating a session. If the user doesn't exist on our database we add them.
// check if we have a similar account
const existingAccount = await getAccountByProviderUserId(
googleUser.sub as string,
);
// The account exists in our database
if (existingAccount) {
await createSession({
userId: existingAccount.userId,
userRole: existingAccount.user.role,
_v: existingAccount.user.refreshTokenVersion,
});
return new Response(null, {
status: 302,
headers: {
Location,
},
});
}
// the account doesn't exist in our database and so we will create a new user and a new account
const { sub, email, name, picture, exp } = googleUser as GoogleUser;
// create the user
const user = //CREATE A USER
// create the account in our db
// create session
await createSession({
userId: user.id,
userRole: user.role,
_v: user.refreshTokenVersion,
});
return new Response(null, { status: 302, headers: {Location } });
}
- Check if we have a code, and verify it
if(code) {
// verify that code using the contact information provided by the user in step 2 or 3
}
- Check if we are using a phone number
if(telephone) {
// verify that code using the phone number provided by the user
}
- Check if we are using an email
if(email) {
// verify that code using the email provided by the user
}
- If we don't have a code, send them a verification code
else {
// verify that code using the contact provided by the user
// (email) or (telephone)
}
- Get the current user by checking the session
//getting to know if they are verified
const user = await getCurrentUser();
if (!user) return { error: 'Unauthorized!' };
- Validate the data
// validate the data
const isValid = SettingsSchema.safeParse(values);
if (!isValid.success) return { error: 'The data is invalid' };
- Remove the email and password of a user if they are an oAuth user
// Removing the email and password of the user is they are an oAuth user
if (user.accounts.length) {
values.password = undefined;
values.email = undefined;
}
- Pass some data validation checks
if (user.id !== existingUser?.id)
// Return an error if the user's don't match
return { error: 'Unauthorized!' };
// Return an error if we do not have a valid user
if (!existingUser) return { error: 'User does not exist' };
- If we have a code, we can validate a new user
if (code) {
if (telephone) {
// verify the user through their telephone
}
if (email) {
// verify the user through their email
}
}
- If we don't have a code, we can send the user a code so that they can resend it with a request
else {
if (telephone) {
// send a telephone code to the user
}
if (email) {
// send a telephone email to the user
}
}
- Changing roles. Only allow the user to change their roles if they are admin. This shows how we can restrict users from doing something if they aren't an ADMIn
// trying to change USER to ADMIN
if (role !== user.role && user.role !== 'ADMIN')
return { error: 'Only admins can change their roles' };
if (password && newPassword && existingUser.password) {
// Return an error if the passwords do not match
const passwordsMatch = bcrypt.compareSync(password, existingUser.password);
if (!passwordsMatch) return { error: 'Incorrect Password!' };
const hash = bcrypt.hashSync(newPassword, 12);
values.password = hash;
}
- Updating a user
await db.user.update({
where: { id: existingUser.id },
data: {
email: values.email || undefined,
telephone: telephone || undefined,
fullName: `${firstName} ${lastName}`,
password: values.password || undefined,
isTwoFactorEnabled,
role,
},
});
There's a lot happening in the application. I couldn't cover it all in the documentation, but by following the functions in the code, you can get to see how everything works together and how sessions are created. I used jose
for managing the JWTs. Shadcn for the components, Resend for the emails, Twilio for SMS, Postgres for the DB although we didn't interact with it directly because we have PRISMA
client and bcryptjs all that I haven't explicitly covered in these docs.
By exploring the code, you will get to see how everything works together. Enjoy!