“Email Invite” Auth Flow w/ NextJS, NextAuth, and MongoDB Adapter

D Hunter
22 min readAug 6, 2023

--

I’ve done a fair amount of searching and struggled to find a great “Invite User” auth flow via the Email provider for NextAuth.js, so I’ve decided to put one together. Hopefully, this walkthrough helps provide a useful starting point for others to work from in their own application!

Note: I highly recommend referencing this tutorial from Auth.js for additional context before getting started: https://authjs.dev/getting-started/email-tutorial

Tech Stack

  • NextJS 13 (pages directory)
  • TailwindCSS
  • Typescript
  • NextAuth v4
  • MongoDB Atlas Cloud
  • Nodemailer
  • SendGrid

Project Setup

Create NextJS Application

The first thing you are going to set up is your NextJS application. Open up a terminal and change the directory to wherever you store your applications. In your terminal, run one of the following commands (select the one associated with your preferred package manager):

npx create-next-app@latest

yarn create next-app

pnpm create next-app

In order to follow along with this tutorial, enter the following prompts accordingly:

What is your project named?  nextauth-invite
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? No
Would you like to use App Router? (recommended) No
Would you like to customize the default import alias? No

Start by clearing out the boilerplate project content:

  • Delete everything from styles/globals.css except for the 3 @tailwind includes
  • Delete everything inside of the <main> tags from the pages/index.tsx file
  • Delete the pages/api/hello.ts API file

Change directory into your application:

cd nextauth-invite

Install Dependencies

Run the install command associated with your package manager:

npm i next-auth nodemailer @auth/mongodb-adapter mongodb @types/nodemailer

yarn add next-auth nodemailer @auth/mongodb-adapter mongodb @types/nodemailer

pnpm add next-auth nodemailer @auth/mongodb-adapter mongodb @types/nodemailer

Set up MongoDB Atlas Account


I may add this in later, but for now you’re own your own. There are plenty of good tutorials already out there for how to create a MongoDB Atlas account and generate the necessary credentials for this project.

You can generally follow the tutorial provided by MongoDB: MongoDB Atlas Tutorial

  • Use a “Shared Cluster” for a simple and free (depending on usage) tier
  • Make sure to select “Node.js” when selecting the “Connect your application” connection method
  • You can stop after “Generating a Database Connection String”

Set up SendGrid Account


I may add this in later, but for now you’re own your own. SendGrid has a very user-friendly setup process; simply make sure that you are selecting the “SMTP Relay” option when going through the Email API > Integration Guide.

Configure .env File

Create a .env.local file in the root folder of your project. Be sure that your .gitignore excludes local env files in order to prevent the exposure of sensitive data from reaching the public. Open your .env.local file and add the following values:

// .env.local

# NextAuth Variables
NEXTAUTH_URL=http://localhost:3000/
NEXTAUTH_SECRET=<randomString>

# MongoDB Variables
MONGODB_DB=<databasename>
MONGODB_URI=mongodb+srv://<user>:<password>@<cluster>.<id>.mongodb.net/$MONGODB_DB

# SendGrid Variables
SMTP_USER=apikey
SMTP_PASSWORD=<apiKey>
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
EMAIL_FROM=<emailAddress>

Note: Replace all <variables> above with your respective values; if using SendGrid, the SMTP_USER value is actually just the string “apikey”. You’ll also want to be sure to update NEXTAUTH_URL to your production environment URL when deploying to production.

Now that you’ve got your initial project set up, you can go ahead and start your development server! Run the associated command in your terminal:

npm run dev

yarn dev

Configure NextAuth API Routes

Next, you’re going to initialize your NextAuth API routes and provider configurations by creating the following catch-all route: pages/api/auth/[…nextauth].ts

Initial Structure

Open up your […nextauth].ts file and add the following code to get started:

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import type { NextAuthOptions } from "next-auth"

export const authOptions: NextAuthOptions = {
providers: []
}

export default NextAuth(authOptions)

Note: Setting up your configuration options in a separate authOptions variable allows you to export it for use when accessing the session on the server.

Set Up Email Provider

Next, you’re going to import and add the Email provider to your configuration options, reading from your environment variables for the server connection settings:

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import type { NextAuthOptions } from "next-auth"
import Email from "next-auth/providers/email"

export const authOptions: NextAuthOptions = {
providers: [
Email({
server: {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
},
from: process.env.EMAIL_FROM,
}),
],
}

export default NextAuth(authOptions)

Set Up Database Client & Adapter

For your Email provider to work, the next thing you are going to need to set up is the database adapter which will store account, session, user, and verification data. Start by creating a MongoDB client:

// lib/mongodb/client.ts

// This approach is taken from https://github.com/vercel/next.js/tree/canary/examples/with-mongodb
import { MongoClient } from "mongodb";

if (!process.env.MONGODB_URI) {
throw new Error('Invalid/Missing environment variable: "MONGODB_URI"');
}

const uri = process.env.MONGODB_URI;
const options = {};

let client;
let clientPromise: Promise<MongoClient>;

// Added for typing of globalThis index signature
// Remove this variable and replace all "globalWithMongo"
// references with "global" if not using typescript
let globalWithMongo = global as typeof globalThis & {
_mongoClientPromise: Promise<MongoClient>
}

if (process.env.NODE_ENV === "development") {
// In development mode, use a global variable so that the value
// is preserved across module reloads caused by HMR (Hot Module Replacement).
if (!globalWithMongo._mongoClientPromise) {
client = new MongoClient(uri, options);
globalWithMongo._mongoClientPromise = client.connect();
}
clientPromise = globalWithMongo._mongoClientPromise;
} else {
// In production mode, it's best to not use a global variable.
client = new MongoClient(uri, options);
clientPromise = client.connect();
}

// Export a module-scoped MongoClient promise. By doing this in a
// separate module, the client can be shared across functions.
export default clientPromise;

Now that you have your MongoDB client set up, add the adapter to your NextAuth configuration options:

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import type { NextAuthOptions } from "next-auth"
import Email from "next-auth/providers/email"
import { MongoDBAdapter } from "@auth/mongodb-adapter"
import clientPromise from "@/lib/mongodb/client"

export const authOptions: NextAuthOptions = {
providers: [
Email({
server: {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
},
from: process.env.EMAIL_FROM,
}),
],
// @ts-expect-error
adapter: MongoDBAdapter(clientPromise),
session: {
strategy: 'jwt',
},
}

export default NextAuth(authOptions)

I’ve added a @ts-expect-error flag to the adapter, as the MongoDBAdapter has a type error associated with the createUser type from Auth.js core Adapter interface. I have not dug in to properly resolve the Typescript error.

Note: By default, adding an adapter to your NextAuth configuration options defaults the session strategy to “database” rather than “jwt”. We are going to be using NextJS middleware with the withAuth() wrapper from NextAuth for restricting page access. withAuth() currently only supports JWT, so you’ll see that the session strategy has been explicitly set to “jwt” in this configuration.

Client Side Functionality

Add Session Context Wrapper

Now it’s time to add your SessionProvider for the application’s session context. Open your _app.tsx file and update it accordingly:

// _app.tsx

import "@/styles/globals.css"
import type { AppProps } from "next/app"
import { SessionProvider } from "next-auth/react"

export default function App({
Component,
pageProps: { session, ...pageProps }
}: AppProps) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
)
}

At this point, you should have a fully functional Passwordless Email Sign In set up! You can verify by navigating to http://localhost:3000/api/auth/signin and attempting to sign in with an email address.

Create a Custom Sign In Page

While the built-in pages from NextAuth are functional, your application will look a lot more professional with a dedicated “sign in” page, so let’s get that set up next. For simplicity, you will be using the project’s homepage (pages/index.tsx) as the “sign in” page of your application. Open your pages/index.tsx file and update the code with the following:

// pages/index.tsx

import { signIn } from "next-auth/react"
import { useState } from 'react'

export default function Home() {

// State Variables
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [formType, setFormType] = useState('signIn')

/**
* * Handle Sign In
* @dev handler for more control over the sign in user experience
* @param e formEvent
*/
const handleSignIn = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
await signIn('email', { email, callbackUrl: '/admin/dashboard' })
setLoading(false)
}

return (
<main className='bg-slate-300 relative flex min-h-screen flex-col items-center justify-center p-24'>
<div className="bg-white py-8 px-20 shadow-black shadow-sm rounded-sm">
{/* Logo */}
<div className="mb-8 text-center">
YOUR_LOGO
</div>

{/* Form */}
<div className="w-full max-w-sm">
<h1 className="font-bold text-3xl mb-6 text-center">{formType === 'signIn' ? 'Sign In' : 'Create Your Account'}</h1>
<form onSubmit={handleSignIn}>

<div className="relative">
<label
htmlFor="email"
className="absolute -top-2 left-2 text-xs bg-white px-1 font-medium"
>
Email Address
</label>
<input
type="email"
id="email"
name="email"
autoComplete='email'
placeholder="email@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full border border-neutral-400 placeholder:text-neutral-400 py-2.5 px-5 rounded-sm"
/>
</div>

<button
type="submit"
disabled={loading}
className="block w-full py-2.5 px-5 mt-4 bg-cyan-600 text-white text-center rounded-sm"
>
{loading ? 'Please Wait...' : 'Continue'}
</button>
</form>

<p className="text-center text-sm mt-4">
{formType === 'signIn' ? 'Don\'t have an account?' : 'Already have an account?'}
<button
className="inline-block text-cyan-600 ml-2"
onClick={() => setFormType(formType === 'signIn' ? 'signUp' : 'signIn')}
>{formType === 'signIn' ? 'Sign up' : 'Sign in'}</button>
</p>
</div>

{/* Bottom Links */}
<div className="text-center text-sm mt-12">
<a
href="#"
className="transition duration-300 text-cyan-600 mx-2"
>Terms of Use</a>
{' '}|{' '}
<a
href="#"
className="transition duration-300 text-cyan-600 mx-2"
>Privacy Policy</a>
</div>
</div>
</main>
)
}

Note: This is a simple boilerplate I’ve put together for your “sign in” page and you are welcome to replace it with something of your own design. The most important part here is that there is an input field to enter an email address and a button which calls signIn(‘email’, { email, callbackUrl: ‘/admin/dashboard’ }) to trigger the passwordless email auth flow.

The next thing you will need to do is update your NextAuth options configuration to reflect the custom “sign in” page. Open your […nextauth].ts file and add the associated “pages” option:

// pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth"
import type { NextAuthOptions } from "next-auth"
import Email from 'next-auth/providers/email'
import { MongoDBAdapter } from "@auth/mongodb-adapter"
import clientPromise from "@/lib/mongodb/client"

export const authOptions: NextAuthOptions = {
providers: [
Email({
server: {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
},
from: process.env.EMAIL_FROM,
}),
],
// @ts-expect-error
adapter: MongoDBAdapter(clientPromise),
session: {
strategy: 'jwt',
},
pages: {
signIn: '/',
},
}

export default NextAuth(authOptions)

Create a Dashboard Page

Now you can create a “dashboard” page to direct users to once they’re logged in. Create an /admin/dashboard folder path inside of your /pages folder and add an index.tsx file to your new /dashboard directory. Open that file and add the following code which will include a link to the “invite” page you are going to create, as well as a link to “sign out”.

// pages/admin/dashboard/index.tsx

import { signOut } from "next-auth/react"
import Link from "next/link"

export default function DashboardPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<section className="text-center">
<h1 className="text-5xl mb-4">Welcome to the Dashboard</h1>
<h2 className="text-2xl">You have reached a secure area!</h2>
<div className="mt-8">
<Link
className="inline-block py-2.5 px-5 bg-cyan-600 text-white rounded-sm"
href="/admin/invite"
>Invite Users</Link>
</div>
<div className="mt-8">
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="text-cyan-600 rounded-md"
>Sign Out</button>
</div>
</section>
</main>
)
}

Add Middleware for Page Restrictions

Now that you have a “sign in” and “dashboard” page, it’s time to set up the initial page restrictions for your application. Create a middleware.ts file in your root folder of your project and add the following code:

// middleware.ts

import { NextRequestWithAuth, withAuth } from "next-auth/middleware"

export default withAuth(
// middleware is triggered AFTER callbacks, allowing for additional authentication logic after the callbacks have been completed
function middleware(req: NextRequestWithAuth) {
console.log('middleware/token', req.nextauth.token)
},
{
// callbacks are triggered first
callbacks: {
// the authorized callback restricts all matched paths, redirecting unauthorized responses to our signIn page
authorized: (params) => {
let { token } = params
return !!token // returns true (indicating "authorized" status) if token is not null (indicating a logged in status)
}
},
pages: {
signIn: '/',
},
}
)

// This middleware will only restrict paths defined in the matcher array below
export const config = { matcher: ["/admin/:path*"] }

With the use of the “matcher”, this middleware will run on all pages inside of the “/admin” route of your application page, first verifying an authorized status by checking for an existing token, and then logging the JWT token values to the console. Any users who have not signed in will not have a valid JWT token and, if attempting to access any page within the “/admin” route will be redirected to the “sign in” page. Give it a try! Make sure to sign out, if you completed the sign in auth flow earlier.

Note: There are multiple methods to apply page restriction to your application and you can use whichever method works best for you.

Add Invite User Auth Flow

Now it’s time to expand the auth flow functionality to allow authenticated users to invite users to your application!

Create an Email Invite Provider

In order to separate the “email invite” flow from the standard “email” sign in flow, you can simply copy the existing “Email” provider and add some minor adjustments. Since you will be duplicating configuration settings, start by creating a new folder called “smtpRelay” inside of your /lib folder. Add a config.ts file to the smtpRelay folder and copy in the associated server configuration settings:

// lib/smtpRelay/config.ts

const smtpRelayConfig = {
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
}

export default smtpRelayConfig

Now open your […nextauth].ts file and update the code. You’ll see that we’ve used the sendVerificationRequest function to override the default email notification functions for our “invite” emails — a process taken from the official NextAuth Email documentation. I’ve also added an interface extension to include Typescript support for modifying the id and name properties of the Email provider’s EmailUserConfig interface.

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import type { NextAuthOptions, Theme } from "next-auth"
import Email, { EmailUserConfig } from 'next-auth/providers/email'
import { MongoDBAdapter } from "@auth/mongodb-adapter"
import clientPromise from "@/lib/mongodb/client"
import smtpRelayConfig from "@/lib/smtpRelay/config"
import { createTransport } from "nodemailer"

interface ExtendedEmailUserConfig extends EmailUserConfig {
id?: string
name?: string
}

/**
* Email HTML body
* Insert invisible space into domains from being turned into a hyperlink by email
* clients like Outlook and Apple mail, as this is confusing because it seems
* like they are supposed to click on it to sign in.
*
* @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
*/
function htmlInvite(params: { url: string; host: string; theme: Theme }) {
const { url, host, theme } = params

const escapedHost = host.replace(/\./g, "&#8203;.")
const brandColor = theme.brandColor || "#346df1"
const buttonText = theme.buttonText || "#fff"

const color = {
background: "#f9f9f9",
text: "#444",
mainBackground: "#fff",
buttonBackground: brandColor,
buttonBorder: brandColor,
buttonText,
}

return `
<body style="background: ${color.background};">
<table width="100%" border="0" cellspacing="20" cellpadding="0"
style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center"
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
You have been invited to <strong>${escapedHost}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
target="_blank"
style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Accept Invite</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
<strong>Note:</strong> If you were not expecting this invitation, you can ignore this email. did not request this email you can safely ignore it.
</td>
</tr>
<tr>
<td
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
<strong>Button not working?</strong> Copy and paste this link into your browser: ${url}
</td>
</tr>
</table>
</body>
`
}

/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function textInvite({ url, host }: { url: string; host: string }) {
return `Accept invite to to ${host}\n${url}\n\n`
}

const emailOptions: ExtendedEmailUserConfig = {
id: 'email',
name: 'Email',
server: smtpRelayConfig,
from: process.env.EMAIL_FROM,
}

const inviteEmailOptions: ExtendedEmailUserConfig = {
id: 'emailInvite',
name: 'Email Invite',
server: smtpRelayConfig,
from: process.env.EMAIL_FROM,
sendVerificationRequest: async (params) => {
const { identifier, url, provider, theme } = params
const { host } = new URL(url)
const transport = createTransport(provider.server)
const result = await transport.sendMail({
to: identifier,
from: provider.from,
subject: `You have been invited to ${host}`,
text: textInvite({ url, host }),
html: htmlInvite({ url, host, theme }),
})
const failed = result.rejected.concat(result.pending).filter(Boolean)
if (failed.length) {
throw new Error(`Email Invite (${failed.join(", ")}) could not be sent`)
}
}
}

export const authOptions: NextAuthOptions = {
providers: [
Email(emailOptions),
Email(inviteEmailOptions),
],
// @ts-expect-error
adapter: MongoDBAdapter(clientPromise),
session: {
strategy: 'jwt',
},
pages: {
signIn: '/',
},
}

export default NextAuth(authOptions)

Create an Invite Page

Go ahead and create an invite/index.tsx route in your application and paste in the revised “sign in” code used on the homepage:

// pages/admin/invite/index.tsx

import { signIn } from "next-auth/react"
import Link from "next/link"
import { useState } from 'react'

export default function Invite() {

// State Variables
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)

/**
* * Handle Send Invite
* @dev handler for more control over the send invite user experience
* @param e formEvent
*/
const handleSendInvite = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
await signIn('emailInvite', { email, callbackUrl: '/admin/view-invites' })
setLoading(false)
}

return (
<main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
<div className="bg-white p-20 rounded-md shadow-black shadow-sm">
{/* Logo */}
<div className="mb-8 text-center">
YOUR_LOGO
</div>

{/* Form */}
<div className="w-full max-w-sm">
<h1 className="font-bold text-3xl mb-6 text-center">Invite User</h1>
<form onSubmit={handleSendInvite}>
<div className="relative">
<label
htmlFor="email"
className="bg-white absolute -top-2 left-2 text-xs px-1 font-medium"
>
Email Address
</label>
<input
type="email"
id="email"
name="email"
autoComplete='email'
placeholder="email@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full border border-neutral-400 placeholder:text-neutral-400 py-2.5 px-5 rounded-sm"
/>
</div>
<button
type="submit"
disabled={loading}
className="block w-full py-2.5 px-5 mt-4 bg-cyan-600 text-white text-center rounded-sm"
>
{loading ? 'Please Wait...' : 'Send Invite'}
</button>
<p className="text-center text-sm mt-4">
Return to the{' '}
<Link
href="/admin/dashboard"
className="inline-block text-cyan-600"
>Dashboard</Link>
</p>
</form>
</div>
</div>
</main>
)
}

Prevent Redirect from Email Invite Auth Flow

Your “Email Invite” functionality should be working nicely, however, it is still currently following the standard signIn auth flow and redirecting your user to a NextAuth verify-request page. While you could create a custom “verify request” page in the same fashion as your custom “sign in” page, you can simplify things slightly by preventing the standard redirect and handling the response directly on the invite page. Open invite/index.tsx and update the handleSendInvite function accordingly:

// pages/admin/invite/index.tsx

import { signIn } from "next-auth/react"
import Link from "next/link"
import { useState } from 'react'

export default function Invite() {

// State Variables
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)

/**
* * Handle Send Invite
* @dev handler for more control over the send invite user experience
* @param e formEvent
*/
const handleSendInvite = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)

// Sned the email invite request
const resp = await signIn(
'emailInvite',
{
email,
callbackUrl: '/auth/view-invites',
redirect: false
}
)

// Add custom event handlers based on response received
if (resp?.error) {
alert(resp.error)
} else if (!resp?.ok) {
alert('An unknown error occurred!')
} else {
setEmail('')
alert(`Email invite to ${email} sent successfully!`)
}
setLoading(false)
}

return (
<main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
<div className="bg-white p-20 shadow-black shadow-sm rounded-sm">
{/* Logo */}
<div className="mb-8 text-center">
YOUR_LOGO
</div>

{/* Form */}
<div className="w-full max-w-sm">
<h1 className="font-bold text-3xl mb-6 text-center">Invite User</h1>
<form onSubmit={handleSendInvite}>

<div className="relative">
<label
htmlFor="email"
className="bg-white absolute -top-2 left-2 text-xs px-1 font-medium"
>
Email Address
</label>
<input
type="email"
id="email"
name="email"
autoComplete='email'
placeholder="email@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full border border-neutral-400 placeholder:text-neutral-400 py-2.5 px-5 rounded-sm"
/>
</div>

<button
type="submit"
disabled={loading}
className="block w-full py-2.5 px-5 mt-4 bg-cyan-600 text-white text-center rounded-sm"
>
{loading ? 'Please Wait...' : 'Send Invite'}
</button>
<p className="text-center text-sm mt-4">
Return to the{' '}
<Link
href="/admin/dashboard"
className="inline-block text-cyan-600"
>Dashboard</Link>
</p>
</form>
</div>
</div>
</main>
)
}

Now you’re getting somewhere! Your application allows users to sign up, sign in, and invite other users. Unfortunately, your callback URL sends users to a page that does not currently exist, so go ahead and create that now!

Create View Invites Page

In order to allow your users with invites to actually review and accept their invitations, create a “view invites” page for them to access their associated invite records. Create a /view-invites folder inside of your /pages/admin folder and add an index.tsx file. Go ahead and use the following code for a base page structure:

// pages/admin/view-invites/index.tsx

import Link from "next/link"
import { useState } from "react"

export default function ViewInvites() {

const [invites, setInvites] = useState([])

return (
<main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
<div className="bg-white p-20 shadow-black shadow-sm rounded-sm">
{/* Logo */}
<div className="mb-8 text-center">
YOUR_LOGO
</div>

{/* Invites */}
<div className="w-full max-w-sm">
<h1 className="font-bold text-3xl mb-6 text-center">View Invites</h1>
<div>
{
!invites.length ? (
<div>
<p>You have not received any new invites.</p>
</div>
) : (
<div>
{/* Invite Card Component Will Go Here */}
</div>
)
}
</div>
</div>
<p className="text-center text-sm mt-4">
Return to the{' '}
<Link
href="/admin/dashboard"
className="inline-block text-cyan-600"
>Dashboard</Link>
</p>
</div>
</main>
)
}

At this point, you have a page which sets an “invites” state variable to an empty array and a mapping of that variable which will output the user’s active invite records or a paragraph reading “You have not received any new invites” if no invite records exists. Your next step will be to add the creation of an invite record to your Email Invite auth flow and then fetch that data on this page to allow users to view and accept/reject their invites.

Create InviteToken DB Model

To help keep your database organized, start by creating a model to represent what your invite token documents will look like (the values you want to use in your invite). For the purpose of this tutorial, keep it simple with id, expires, team, invitedBy, and invited properties — you can expand as needed to capture and work with additional values within your own application. Create a /models folder in the root folder of your project. Then, create an InviteToken.ts file and add the following code:

// models/InviteToken.ts

import { ObjectId } from "mongodb"

export interface InviteToken {
id?: ObjectId
expires: Date
team: string
invitedBy: string
invited: string
}

Create Invite API Endpoint

Next, create a /invites folder inside of your /pages/api folder and then create an index.ts file. Open the index.ts file and add the following code:

// pages/api/invites/index.ts

import { getServerSession } from "next-auth/next"
import { authOptions } from "@/pages/api/auth/[...nextauth]"
import { NextApiRequest, NextApiResponse } from "next"
import clientPromise from '@/lib/mongodb/client'
import { InviteToken } from "@/models/InviteToken"
import { ObjectId } from "mongodb"
const database = process.env.MONGODB_DB

export default async function inviteHandler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
const { email } = JSON.parse(req.body)
const session = await getServerSession(req, res, authOptions)

// If no session exists, return an error response and appropriate message
if (!session?.user?.email)
return res.status(401).json({ ok: false, message: 'Valid session not found.' })

switch (method) {
case 'POST':
try {
// Connect to MongoDB
const client = await clientPromise
const db = client.db(database)

// Set expiration date
const expirationDate = new Date()
expirationDate.setDate(expirationDate.getDate() + 7)

// Insert a new document into the invite_tokens collection
const insert = await db.collection<InviteToken>('invite_tokens').insertOne({
expires: expirationDate,
team: session.user.email,
invitedBy: session.user.email,
invited: email,
})

// If insert acknowledgement is false, something went wrong and we should return an error response
if (!insert.acknowledged)
return res.status(500).json({ ok: false, message: 'An error occurred during insert', insert })

res.status(200).json({ ok: true, message: 'Invite posted successfully', insert })

} catch (error) {
return res.status(500).json({ ok: false, message: 'An unexpected error occurred', error })
}

break

default:
res.setHeader('Allow', ['POST'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}

Note: While we’ve added an “expires” value here, you will need to actually set up a MongoDB trigger or similar automated process to clear out those expired tokens to make them truly timebound. We will not be covering that in this runthrough. Additionally, you will see that the “team” value is currently just the email address of the user who sent the invite; in your application, you will most likely have other values associated with the inviting user to utilize in your invite records.

Add Create Invite Token to Invite Page

Now that you have an API endpoint for creating invite records, you need to add a function to your invite page to trigger a POST command to that endpoint. Open your “invite” page, add the createInviteToken function, and update the inviteHandler function accordingly:

// pages/admin/invite/index.tsx

import { signIn } from "next-auth/react"
import Link from "next/link"
import { useState } from 'react'

export default function Invite() {

// State Variables
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)

/**
* * Create Invite Token
* @dev Creates an invite token in the database
* @returns JSON object
*/
const createInviteToken = async () =>

const resp = await fetch('/api/invites/', {
method: 'POST',
body: JSON.stringify({ email })
})

const respJson = await resp.json()

return respJson
}

/**
* * Handle Send Invite
* @dev handler for more control over the send invite user experience
* @param e formEvent
*/
const handleSendInvite = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)

// Sned the email invite request
const resp = await signIn(
'emailInvite',
{
email,
callbackUrl: '/auth/accept-invite',
redirect: false
}
)

// Add custom event handlers based on response received
if (resp?.error) {
alert(resp.error)
} else if (!resp?.ok) {
alert('An unknown error occurred!')
} else {
// Create an invite token
await createInviteToken()
alert(`Email invite to ${email} sent successfully!`)
setEmail('')
}

setLoading(false)
}

return (
<main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
<div className="bg-white p-20 shadow-black shadow-sm rounded-sm">
{/* Logo */}
<div className="mb-8 text-center">
YOUR_LOGO
</div>

{/* Form */}
<div className="w-full max-w-sm">
<h1 className="font-bold text-3xl mb-6 text-center">Invite User</h1>
<form onSubmit={handleSendInvite}>

<div className="relative">
<label
htmlFor="email"
className="bg-white absolute -top-2 left-2 text-xs px-1 font-medium"
>
Email Address
</label>
<input
type="email"
id="email"
name="email"
autoComplete='email'
placeholder="email@example.com"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full border border-neutral-400 placeholder:text-neutral-400 py-2.5 px-5 rounded-sm"
/>
</div>

<button
type="submit"
disabled={loading}
className="block w-full py-2.5 px-5 mt-4 bg-cyan-600 text-white text-center rounded-sm"
>
{loading ? 'Please Wait...' : 'Send Invite'}
</button>
<p className="text-center text-sm mt-4">
Return to the{' '}
<Link
href="/admin/dashboard"
className="inline-block text-cyan-600"
>Dashboard</Link>
</p>
</form>
</div>
</div>
</main>
)
}

With that, you should now be able to send some invites! Sign in to your application and send an invite to an alternate email address (or with the lax restrictions currently in place you can send an invite to yourself as well).

Add Get Invites to Invite API Endpoint

Now that you have some invite token records in your database, open your /pages/api/invites/index.ts file and update your API endpoint to query invite records based on the signed in user’s email address when it receives a GET request:

// pages/api/invite/index.ts

import { getServerSession } from "next-auth/next"
import { authOptions } from "@/pages/api/auth/[...nextauth]"
import { NextApiRequest, NextApiResponse } from "next"
import clientPromise from '@/lib/mongodb/client'
import { InviteToken } from "@/models/InviteToken"
import { ObjectId } from "mongodb"
const database = process.env.MONGODB_DB

export default async function inviteHandler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
const session = await getServerSession(req, res, authOptions)

// If no session exists, return an error response and appropriate message
if (!session?.user?.email)
return res.status(401).json({ ok: false, message: 'Valid session not found.' })

switch (method) {
case 'POST':
const { email } = JSON.parse(req.body)

try {
// Connect to MongoDB
const client = await clientPromise
const db = client.db(database)

// Set expiration date
const expirationDate = new Date()
expirationDate.setDate(expirationDate.getDate() + 7)

// Insert a new document into the invite_tokens collection
const insert = await db.collection<InviteToken>('invite_tokens').insertOne({
expires: expirationDate,
team: session.user.email,
invitedBy: session.user.email,
invited: email,
})

// If insert acknowledgement is false, something went wrong and we should return an error response
if (!insert.acknowledged)
return res.status(500).json({ ok: false, message: 'An error occurred during insert', insert })

res.status(200).json({ ok: true, message: 'Invite posted successfully', insert })

} catch (error) {
return res.status(500).json({ ok: false, message: 'An unexpected error occurred', error })
}

break

case 'GET':
try {
// Connect to MongoDB
const client = await clientPromise
const db = client.db(database)

// Query session user's invite records from the invite_tokens collection
const inviteRecords = await db.collection<InviteToken>('invite_tokens').find({ invited: session.user.email }).toArray()

res.status(200).json({ ok: true, message: 'Invite records queried successfully', inviteRecords })

} catch (error) {
return res.status(500).json({ ok: false, message: 'An unexpected error occurred', error })
}

break

default:
res.setHeader('Allow', ['POST'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}

Add Initialize Invites Function to View Invites Page

Now that you have an API endpoint to retrieve invite token records, open your /pages/admin/view-invites/index.tsx file and update the code accordingly:

// pages/admin/view-invites/index.tsx

import Link from "next/link"
import { useEffect, useState } from "react"

interface InviteTokenRecord {
id: string
team: string
invitedBy: string
invited: string
}

export default function ViewInvites() {

// State Variables
const [invites, setInvites] = useState<InviteTokenRecord[] | []>([])

const acceptInvite = async (id: string) => {
// Code to handle accepted invites
// e.g., add team to user record in users collection and update invite record to indicate acceptance or delete
}

const rejectInvite = async (id: string) => {
// Code to handle rejected invites
// e.g., update invite record to indicate rejection or delete
}

// Set initial invite token records
useEffect(() => {
const initializeInvites = async () => {
try {
const resp = await fetch('/api/invites')
const respJson = await resp.json()

setInvites(respJson.inviteRecords)
} catch (error) {
alert('An unexpected error occurred')
console.error(error)
}

}
initializeInvites()
}, [])

return (
<main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
<div className="bg-white p-20 shadow-black shadow-sm rounded-sm">
{/* Logo */}
<div className="mb-8 text-center">
YOUR_LOGO
</div>

{/* Invites */}
<div className="w-full max-w-sm">
<h1 className="font-bold text-3xl mb-6 text-center">View Invites</h1>

{
!invites.length ? (
<p>You have not received any new invites.</p>
) : (
<div>
{invites.map((invite, i) => (
<div
className="bg-slate-300 p-8"
key={i}>
<ul>
<li>Team: {invite.invitedBy}</li>
<li>Invited By: {invite.invitedBy}</li>
</ul>

<div className="flex items-center justify-center gap-4 mt-8">
<button className="bg-cyan-500 text-white py-1.5 px-5 rounded-sm" onClick={() => acceptInvite(invite?.id)}>Accept</button>
<button className="bg-slate-800 text-white py-1.5 px-5 rounded-sm" onClick={() => rejectInvite(invite?.id)}>Reject</button>
</div>
</div>
))}
</div>
)
}

</div>
<p className="text-center text-sm mt-4">
Return to the{' '}
<Link
href="/admin/dashboard"
className="inline-block text-cyan-600"
>Dashboard</Link>
</p>
</div>
</main>
)
}

Conclusion

OK! At this point, you should have an application that allows users to sign up (register), sign in, and invite other users. Your email invite auth flow now also includes the creation of invite tokens, which you can use to store information about the inviter and invitee as well as allow users to accept/reject their invite tokens. We’re going to go ahead and stop here as this will hopefully provide enough of a launch pad to continue expanding on “invite user” functionality! Continued expansion to consider might include the following:

  • Add a link in your dashboard to access the “View Invites” page
  • Expand the invite API endpoint and “View Invites” page to query the database for invite tokens where the signed in user exists as either the “invited” OR “invitedBy”, allowing for visibility into SENT invites as well as RECEIVED invites
  • Add checks and balances to prevent or better manage duplicate invites
  • Expand the information stored and utilized in the invite token records

I hope this run-through connects some dots or sparks some ideas for you when using NextAuth and considering Invite style functionality!

--

--