Build a Next.js application with Auth.js v5 authentication and MessageBird two-way SMS messaging. Implement secure webhooks for inbound messages, Prisma database integration, and user phone number association.
Build a Next.js application with secure user authentication via Auth.js (v5) and two-way SMS communication powered by MessageBird. Your users will log in, associate their phone number, receive incoming SMS messages sent to a dedicated virtual number, and reply directly through your web application.
You'll implement project setup, authentication configuration, MessageBird webhooks for inbound messages, outbound replies via the MessageBird API, database integration with Prisma, security considerations, and deployment strategies. By the end, you'll have a functional application demonstrating robust integration between these technologies.
Target Audience: Developers familiar with Next.js, React, and basic API concepts.
⚠️ Article Status: This tutorial covers project setup, authentication, and inbound message handling. Sections on sending outbound SMS, building the chat interface, deployment, and advanced security considerations are not included in this version.
Project Overview and Goals
What You'll Build:
A Next.js (App Router) web application where:
Users authenticate using email/password (via Auth.js Credentials provider).
Authenticated users have a phone number associated with their profile (stored via Prisma).
Incoming SMS messages sent to a specific MessageBird virtual number route to your application via webhooks.
Your application identifies the corresponding user based on the sender's phone number (originator).
Inbound messages store in your database and display to the relevant user within the app.
Authenticated users send outbound SMS replies from the application interface to the original sender.
Problem You'll Solve:
This implementation bridges standard web application authentication with real-world SMS communication. It enables features like:
Personalized SMS notifications tied to user accounts.
In-app customer support via SMS.
Two-factor authentication flows (though this guide focuses on general messaging).
Any scenario requiring associating SMS conversations with specific, authenticated users.
Technologies You'll Use:
Next.js (v15 with App Router): React framework for building your frontend and API routes. Uses React 19 support, Turbopack bundling improvements, and modern caching strategies. (As of January 2025, Next.js 15.5+ is current with stable Turbopack and TypeScript improvements.)
Auth.js (v5 – next-auth@5.x): Handles user authentication and session management. Chosen for flexibility, rich provider support, and seamless Next.js integration. (Formerly known as NextAuth.js; v5 is a major rewrite with @auth/core, requiring Next.js 14+ minimum. Official docs at authjs.dev.)
MessageBird: SMS API provider for sending and receiving messages. Chosen for robust API, global reach, and developer-friendly tools like Flow Builder. (MessageBird APIs remain actively maintained as of 2025 with no major deprecations announced.)
Prisma (v6.x): Next-generation ORM for database access (connecting to PostgreSQL, SQLite, etc.). Chosen for type safety, schema management (migrations), and developer productivity. (As of 2025, Prisma 6+ includes Rust-free architecture improvements for better performance and edge compatibility.)
PostgreSQL (or SQLite): Relational database for storing user data, authentication details, and message history. (This guide uses Prisma syntax compatible with various SQL databases).
Tailwind CSS: For styling your user interface.
System Architecture:
graph TD
subgraph "User's Phone"
UPhone[SMS Client]
end
subgraph "MessageBird Platform"
MBNum[Virtual Mobile Number]
MBFlow[Flow Builder]
MBAPI[MessageBird API]
end
subgraph "Next.js Application (Hosted on Vercel/Server)"
subgraph "Frontend (Client Components)"
UI[React UI / Chat Interface]
end
subgraph "Backend (Server Components / API Routes)"
AuthN[Auth.js /auth Route]
Webhook[/api/webhooks/messagebird]
SendAPI[/api/messages/send]
ServerComp[Server Components]
end
DB[(Prisma Client)] -- Interacts --> Database[PostgreSQL / SQLite]
end
User[End User Browser] <--> UI
UI -- Calls API --> SendAPI
UI -- Reads Session --> AuthN
User -- Auth Flow --> AuthN
UPhone -- Sends SMS --> MBNum
MBNum -- Triggers --> MBFlow
MBFlow -- Forwards POST --> Webhook
Webhook -- Stores Msg --> DB
Webhook -- Reads User --> DB
SendAPI -- Uses Session --> AuthN
SendAPI -- Sends via SDK --> MBAPI
SendAPI -- Stores Msg --> DB
MBAPI -- Sends SMS --> UPhone
ServerComp -- Reads Session --> AuthN
ServerComp -- Reads Data --> DB
AuthN -- Manages Auth Data --> DB
Prerequisites:
Node.js (v18 or later) and npm/yarn installed. (Node.js 18.x is the current LTS version as of 2025, with Node.js 20.x also available.)
A MessageBird account with API credentials and a purchased virtual mobile number capable of receiving SMS.
Access to a PostgreSQL database (or setup for SQLite).
Basic understanding of JavaScript, React, Next.js, and REST APIs.
A tool for exposing your local development server to the internet (e.g., Ngrok). (Note: Use Ngrok for local development testing only. Production deployments require a stable, publicly accessible URL for your webhook endpoint.)
1. Setting Up the Project
Initialize your Next.js project and install necessary dependencies.
Create Your Next.js App:
Open your terminal and run:
next-auth: The current package name for Auth.js v5. (Install next-auth@beta or next-auth@5.x to ensure v5 compatibility.)
@auth/prisma-adapter: Adapter for Auth.js to use Prisma. (Note the @auth/ scope, updated from @next-auth/* in v5.)*
prisma, @prisma/client: Prisma ORM and client library.
messagebird: Official MessageBird Node.js SDK.
bcryptjs: For password hashing (required for secure Credentials provider).
prisma (dev dependency): For Prisma CLI commands (init, migrate, generate).
@types/bcryptjs (dev dependency): TypeScript types for bcryptjs.
Initialize Prisma:
Bash
npx prisma init --datasource-provider postgresql
This creates a prisma directory with a schema.prisma file and a .env file at your project root.
For SQLite, change postgresql to sqlite.
Configure Environment Variables:
Open the .env file created by Prisma (or create .env.local for local overrides, which Git ignores). Add these variables:
.env
# .env / .env.local
# Database
# Example for PostgreSQL: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
# Example for SQLite: file:./dev.db
DATABASE_URL="YOUR_DATABASE_CONNECTION_STRING"
# Auth.js (v5+)
# Generate with: openssl rand -hex 32
AUTH_SECRET="YOUR_STRONG_AUTH_SECRET"
# AUTH_URL replaces NEXTAUTH_URL in Auth.js v5+
AUTH_URL="http://localhost:3000" # Change for production!
# MessageBird
MESSAGEBIRD_API_KEY="YOUR_MESSAGEBIRD_LIVE_API_KEY"
# Your virtual number purchased from MessageBird (E.164 format, e.g., +12025550149)
MESSAGEBIRD_SENDER_ID="YOUR_MESSAGEBIRD_VIRTUAL_NUMBER"
# Create a strong, unique secret for verifying webhook requests
MESSAGEBIRD_WEBHOOK_SECRET="YOUR_STRONG_UNIQUE_WEBHOOK_SECRET"
DATABASE_URL: Get this from your database provider (or set for local SQLite).
AUTH_SECRET: Generate a strong secret using openssl rand -hex 32 in your terminal. Crucial for session encryption.
AUTH_URL: The base URL of your application. Essential for OAuth redirects and callbacks. Update this for production. (Note: Auth.js v5 uses AUTH_ prefix instead of NEXTAUTH_. Both are supported via automatic aliasing, but AUTH_ is preferred.)*
MESSAGEBIRD_API_KEY: Find this in your MessageBird Dashboard under Developers > API access > Live API Key.
MESSAGEBIRD_SENDER_ID: Your virtual mobile number purchased from MessageBird, in E.164 format (e.g., +12223334444). This will be the "from" number for outbound messages.
MESSAGEBIRD_WEBHOOK_SECRET: Crucial for security. Create a long, random, unpredictable string. Use this to verify that incoming webhook requests genuinely originate from MessageBird (or at least know the secret).
Project Structure:
Your src/ directory will contain app/ for routes and components, potentially lib/ for utilities (like Prisma client instance, MessageBird client), and components/ for shared UI elements. The prisma/ directory holds your schema and migrations. Configuration files (.env, next.config.js, tsconfig.json) live at the root. This structure promotes separation of concerns.
2. Database Schema and Data Layer (Prisma)
Define your database models for users, authentication, and messages.
Define Your Prisma Schema:
Open prisma/schema.prisma and define the models:
Prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // Or "sqlite", "mysql", "sqlserver"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
passwordHash String? // Store hashed password for Credentials provider
// Add phone number – make it unique to associate messages correctly
phoneNumber String? @unique
accounts Account[]
sessions Session[]
messages Message[] // Relation to messages sent/received by this user
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// Model for storing SMS messages
model Message {
id String @id @default(cuid())
content String
direction String // "inbound" or "outbound"
senderNumber String // Phone number sending the message (E.164)
recipientNumber String // Phone number receiving the message (E.164)
messageBirdId String? // Optional: Store MessageBird message ID
createdAt DateTime @default(now())
// Associate message with a User based on their phone number
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull) // SetNull keeps messages if you delete a user
@@index([userId, createdAt]) // Index for efficient fetching of user messages
@@index([senderNumber]) // Index for webhook lookup
}
Standard Auth.js models (User, Account, Session, VerificationToken).
Added passwordHash (String, nullable) to store the hashed password for the Credentials provider.
Added phoneNumber (String, unique) to the User model.
Added a Message model to store SMS details, including direction, sender/recipient numbers, and a relation to the User model. The userId is nullable and uses onDelete: SetNull so messages persist if you remove a user.
Added indexes for common lookups.
Apply Schema to Your Database:
Run this command to create or update your database tables based on the schema:
Bash
npx prisma db push
For initial development setup, db push is convenient. For production workflows with version control and team collaboration, use npx prisma migrate dev (creates migration files) and npx prisma migrate deploy (applies migrations in production). (Prisma 6.x best practice: Always use migrations for production databases to track schema changes.)
Create Prisma Client Instance:
Create a utility file to instantiate and export the Prisma client, ensuring only one instance exists.
Configure Auth.js to handle user sign-up, sign-in, and session management using the Credentials provider and Prisma adapter.
Configure Auth.js:
Create your main Auth.js configuration file.
TypeScript
// src/auth.tsimportNextAuthfrom'next-auth';
import { PrismaAdapter } from'@auth/prisma-adapter';
importCredentialsfrom'next-auth/providers/credentials';
import prisma from'@/lib/prisma';
import { compare } from'bcryptjs'; // Use compare from bcryptjsimporttype { User } from'@prisma/client'; // Import User type from Prismaimporttype { DefaultSession } from'next-auth'; // Import DefaultSessionimporttype { JWTasNextAuthJWT } from'next-auth/jwt'; // Import JWT typeexportconst { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Credentials({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email', placeholder: 'user@example.com' },
password: { label: 'Password', type: 'password' },
},
asyncauthorize(credentials): Promise<User | null> {
// Validate credentials inputif (!credentials?.email || !credentials.password) {
console.error('Authorize error: Missing email or password');
returnnull;
}
const email = credentials.emailasstring;
const password = credentials.passwordasstring;
// Find user by emailconst user = await prisma.user.findUnique({
where: { email: email },
});
if (!user) {
console.error(`No user found with email: ${email}`);
returnnull; // User not found
}
// !! CRITICAL SECURITY !!// Verify the provided password against the stored hash.// The user MUST have a passwordHash field populated during registration.if (!user.passwordHash) {
console.error(`User ${email} has no password hash set. Cannot authenticate via credentials.`);
returnnull; // Cannot authenticate without a stored hash
}
const isValidPassword = awaitcompare(password, user.passwordHash);
if (!isValidPassword) {
console.error(`Invalid password for user: ${email}`);
returnnull; // Passwords do not match
}
console.log(`Credentials valid for user: ${email}`);
// Return the user object if authentication succeeds// Ensure this object includes fields needed by callbacks (like id, phoneNumber)// The Prisma User type should align if you use the adapter correctly.constauthorizedUser: User = {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
phoneNumber: user.phoneNumber, // Include phoneNumberpasswordHash: null, // Ensure passwordHash never returnscreatedAt: user.createdAt,
updatedAt: user.updatedAt,
};
return authorizedUser;
},
}),
// Add other providers like Google, GitHub etc. if needed// GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }),
],
session: {
strategy: 'jwt', // Recommended strategy, especially with Credentials
},
callbacks: {
// Include user ID and phone number in the JWT tokenasyncjwt({ token, user }) {
if (user) { // user object is available on sign-in
token.id = user.id;
// Add custom properties from the User object returned by authorize/providerconst prismaUser = user asUser; // Cast to Prisma User typeif (prismaUser.phoneNumber) {
token.phoneNumber = prismaUser.phoneNumber;
}
}
return token;
},
// Include user ID and phone number in the session object from the JWTasyncsession({ session, token }) {
if (token && session.user) {
session.user.id = token.idasstring;
if (token.phoneNumber) {
session.user.phoneNumber = token.phoneNumberasstring;
}
}
return session;
},
},
pages: {
signIn: '/login', // Custom login page path// error: '/auth/error', // Optional: Custom error page// newUser: '/register' // Optional: Custom registration page
},
// Add secret from .envsecret: process.env.AUTH_SECRET,
// Enable debug messages in developmentdebug: process.env.NODE_ENV === 'development',
});
// Add custom fields to the default Session User type & JWT type// Ensure these match the fields added in the jwt/session callbacksdeclaremodule'next-auth' {
interfaceSession {
user: {
id: string;
phoneNumber?: string | null; // Add phoneNumber here
} & DefaultSession['user']; // Keep default fields like name, email, image
}
// Extend the User type available in callbacks if needed (e.g., from authorize)// This should align with the Prisma User type or the object returned by authorizeinterfaceUser {
phoneNumber?: string | null;
}
}
declaremodule'next-auth/jwt' {
interface JWT extendsNextAuthJWT { // Extend the imported JWT typeid: string;
phoneNumber?: string | null;
}
}
Uses PrismaAdapter.
Configures the Credentials provider with proper password verification using bcryptjs.compare. Assumes a passwordHash field exists on the User model and is populated during registration.
CRITICAL SECURITY WARNING: You MUST implement user registration separately, ensuring you hash passwords using bcryptjs.hash before storing them in the passwordHash field. This guide doesn't detail the registration flow, but it's essential for the Credentials provider to function securely.
Uses jwt session strategy.
Includes callbacks to add the user's id and phoneNumber to the JWT and session object.
Defines a custom login page path (/login).
Extends the Session, User, and JWT types to include phoneNumber.
Create API Route Handlers:
Set up the catch-all API route for Auth.js.
TypeScript
// src/app/api/auth/[...nextauth]/route.tsexport { GET, POST } from'@/auth';
// export const runtime = "edge"; // Optional: Use Edge Runtime if preferred and compatible
Create Your Login Page:
Build a simple login page component.
Handles submission, calls the credentials provider, manages error display, and redirects using next/navigation.
Includes loading state feedback.
Registration: This form assumes you pre-register users. You need a separate process (e.g., a registration page) to create users, hash their passwords using bcryptjs, and store the hash in the passwordHash field.
Wrap Your App in SessionProvider:
To use client-side hooks like useSession, wrap your application layout.
4. Integrating MessageBird for Inbound SMS (Webhooks)
Set up your webhook endpoint to receive incoming SMS messages from MessageBird.
Create MessageBird Client Instance:
Similar to Prisma, create a utility for your MessageBird client.
TypeScript
// src/lib/messagebird.tsimport initMessageBird from'messagebird';
const apiKey = process.env.MESSAGEBIRD_API_KEY;
if (!apiKey) {
// Log a warning in development, but potentially throw in production if criticalif (process.env.NODE_ENV === 'development') {
console.warn('MessageBird API Key (MESSAGEBIRD_API_KEY) not found in environment variables. MessageBird functionality will be disabled.');
} else {
// In production, this might be a fatal error depending on your requirementsconsole.error('CRITICAL: Missing MESSAGEBIRD_API_KEY environment variable.');
// throw new Error('Missing MESSAGEBIRD_API_KEY environment variable');
}
}
// Initialize only if apiKey exists, otherwise export nullexportconst messagebird = apiKey ? initMessageBird(apiKey asstring) : null;
// Export a function to get the client to handle the null case more gracefully elsewhereexportconstgetMessageBirdClient = () => {
if (!messagebird) {
console.error("Attempted to use MessageBird client, but it's not initialized (missing API key).");
// throw new Error("MessageBird client is not available.");
}
return messagebird;
}
exportdefault messagebird;
Create Your Webhook API Route:
This route listens for POST requests from MessageBird's Flow Builder.
TypeScript
// src/app/api/webhooks/messagebird/route.tsimport { NextRequest, NextResponse } from'next/server';
import prisma from'@/lib/prisma';
import crypto from'crypto'; // For constant-time comparison// Secure Webhook Secret Verification (Constant Time Comparison)// SECURITY BEST PRACTICE: Always verify webhook authenticity to prevent unauthorized accessfunctionverifyWebhookSecret(request: NextRequest): boolean {
const providedSecret = request.nextUrl.searchParams.get('secret');
const expectedSecret = process.env.MESSAGEBIRD_WEBHOOK_SECRET;
if (!expectedSecret) {
console.error('[Webhook Security] MESSAGEBIRD_WEBHOOK_SECRET is not set in environment. Cannot verify webhook.');
returnfalse; // Fail safe
}
if (!providedSecret) {
console.warn('[Webhook Security] Webhook request missing required "secret" query parameter.');
returnfalse;
}
// Use crypto.timingSafeEqual for constant-time comparison to prevent timing attackstry {
const providedBuffer = Buffer.from(providedSecret);
const expectedBuffer = Buffer.from(expectedSecret);
// Ensure buffers are the same length before comparingif (providedBuffer.length !== expectedBuffer.length) {
console.warn('[Webhook Security] Provided secret length mismatch.');
// Still perform a comparison with the expected buffer to ensure constant time
crypto.timingSafeEqual(expectedBuffer, expectedBuffer);
returnfalse;
}
const isEqual = crypto.timingSafeEqual(providedBuffer, expectedBuffer);
if (!isEqual) {
console.warn('[Webhook Security] Invalid webhook secret provided.');
}
return isEqual;
} catch (error) {
console.error('[Webhook Security] Error during secret comparison:', error);
returnfalse;
}
}
// Define the expected structure of the incoming webhook payload// Adjust based on what your MessageBird Flow Builder actually sendsinterfaceMessageBirdWebhookPayload {
originator: string; // Sender's phone number (E.164)recipient: string; // Your MessageBird virtual number (E.164)payload: string; // The SMS message content// Add other fields you might send from Flow BuildermessageId?: string; // Optional: MessageBird's internal IDreceivedAt?: string; // Optional: Timestamp from MessageBird
}
exportasyncfunctionPOST(request: NextRequest) {
console.log('[Webhook] Received request');
// 1. Verify the webhook secretif (!verifyWebhookSecret(request)) {
console.error('[Webhook] Failed webhook secret verification.');
returnNextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
console.log('[Webhook] Secret verified successfully.');
try {
// 2. Parse the incoming JSON payloadconstpayload: MessageBirdWebhookPayload = await request.json();
console.log('[Webhook] Payload received:', payload);
// Basic validation of required fieldsif (!payload.originator || !payload.recipient || !payload.payload) {
console.error('[Webhook] Invalid payload structure. Missing required fields.');
returnNextResponse.json({ error: 'Bad Request: Missing required fields' }, { status: 400 });
}
const senderNumber = payload.originator; // The external user's numberconst virtualNumber = payload.recipient; // Your app's numberconst content = payload.payload;
const messageBirdId = payload.messageId; // Optional// 3. Find the corresponding user in your database// Use the SENDER's number (originator) to find the user who should receive this message in the app// This assumes you store the user's phone number in E.164 format and it's uniqueconst user = await prisma.user.findUnique({
where: { phoneNumber: senderNumber },
});
if (!user) {
// Decide how to handle messages from unknown numbers// Option 1: Ignore and logconsole.warn(`[Webhook] Received message from unknown number: ${senderNumber}. Ignoring.`);
// Option 2: Store message without associating user (set userId to null)// await prisma.message.create({ … data with userId: null … });// Option 3: Trigger an alert or specific flowreturnNextResponse.json({ message: 'User not found for this number' }, { status: 200 }); // Acknowledge receipt but indicate no user match
}
console.log(`[Webhook] Found user ${user.id} associated with number ${senderNumber}`);
// 4. Store the inbound message in your databaseconst newMessage = await prisma.message.create({
data: {
content: content,
direction: 'inbound',
senderNumber: senderNumber, // From the external userrecipientNumber: virtualNumber, // To your virtual numbermessageBirdId: messageBirdId, // Optional MessageBird IDuserId: user.id, // Associate with the found user
},
});
console.log(`[Webhook] Stored inbound message ${newMessage.id} for user ${user.id}`);
// 5. (Optional) Trigger real-time updates (e.g., via WebSockets/Pusher/Server-Sent Events)// This would notify the user's frontend that a new message has arrived.// Example: await triggerRealtimeUpdate(user.id, newMessage);// 6. Respond to MessageBird to acknowledge receipt// A 2xx status code tells MessageBird the webhook was received successfully.returnNextResponse.json({ message: 'Webhook received successfully' }, { status: 200 });
} catch (error) {
console.error('[Webhook] Error processing webhook:', error);
// Handle JSON parsing errors specificallyif (error instanceofSyntaxError) {
returnNextResponse.json({ error: 'Bad Request: Invalid JSON payload' }, { status: 400 });
}
// Generic internal server error for other issuesreturnNextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
Frequently asked questions
Set up two-way SMS by integrating Auth.js for user authentication, MessageBird for SMS communication, and Prisma for database management. This involves configuring API keys, setting up webhooks, and implementing server-side logic to handle incoming and outgoing messages within your Next.js application.
MessageBird is the SMS API provider, enabling sending and receiving text messages. It provides a virtual mobile number to receive inbound messages and an API to send outbound replies from your Next.js application.
Auth.js (v5 or later - next-auth) provides secure user authentication, linking SMS conversations with specific user accounts. This is crucial for personalized notifications, in-app support, and other scenarios requiring user-specific SMS interactions.
Ngrok is primarily for local development to expose your webhook endpoint during testing. For production deployments, use a stable, publicly accessible URL for your webhook.
Yes, Prisma supports various SQL databases like PostgreSQL, SQLite, MySQL, and SQL Server. Configure the provider and DATABASE_URL in your prisma/schema.prisma file accordingly.
Create a dedicated API route (/api/webhooks/messagebird) to receive webhook requests from MessageBird. Verify the webhook secret, parse the message content, identify the user based on the sender's number, and store the message in the database using Prisma.
The webhook secret is crucial for security. It verifies that incoming webhook requests genuinely originate from MessageBird, preventing unauthorized access to your application's data and functionality.
Use the MessageBird API via their Node.js SDK. Within your application, retrieve the recipient's number and the message content, and then use the MessageBird client to send the SMS message. Remember to store the outbound message in your database as well for record-keeping.
The tutorial requires Next.js v14 or later with the App Router enabled. The App Router is chosen for its hybrid rendering capabilities, improved routing, and overall developer experience.
Use the sender's phone number (originator in the webhook payload) to look up the corresponding user in your database. This assumes the user's phone number is stored in the phoneNumber field (unique) in the User model in your Prisma schema.
Prisma is a next-generation ORM (Object-Relational Mapper) that simplifies database interactions. It provides type safety, schema management, and increased developer productivity when working with databases like PostgreSQL or SQLite.
Implement a separate registration process to collect user credentials, hash passwords using bcryptjs, and store only the hash in the passwordHash field of the User model. Never store plain-text passwords. In your Auth.js configuration, always use bcryptjs.compare to verify submitted passwords against the stored hash.
The AUTH_SECRET is essential for session encryption in Auth.js. Generate a strong, random secret using openssl rand -hex 32 and store it securely in your .env file. Never expose this secret publicly.
Build a Next.js application with secure user authentication via Auth.js (v5) and two-way SMS communication powered by MessageBird. Your users will log in, associate their phone number, receive incoming SMS messages sent to a dedicated virtual number, and reply directly through your web application.
You'll implement project setup, authentication configuration, MessageBird webhooks for inbound messages, outbound replies via the MessageBird API, database integration with Prisma, security considerations, and deployment strategies. By the end, you'll have a functional application demonstrating robust integration between these technologies.
Target Audience: Developers familiar with Next.js, React, and basic API concepts.
⚠️ Article Status: This tutorial covers project setup, authentication, and inbound message handling. Sections on sending outbound SMS, building the chat interface, deployment, and advanced security considerations are not included in this version.
Project Overview and Goals
What You'll Build:
A Next.js (App Router) web application where:
originator).Problem You'll Solve:
This implementation bridges standard web application authentication with real-world SMS communication. It enables features like:
Technologies You'll Use:
next-auth@5.x): Handles user authentication and session management. Chosen for flexibility, rich provider support, and seamless Next.js integration. (Formerly known as NextAuth.js; v5 is a major rewrite with @auth/core, requiring Next.js 14+ minimum. Official docs at authjs.dev.)System Architecture:
Prerequisites:
1. Setting Up the Project
Initialize your Next.js project and install necessary dependencies.
Create Your Next.js App: Open your terminal and run:
npx create-next-app@latest nextjs-messagebird-chat --typescript --tailwind --eslint --app --src-dir --use-npm --import-alias "@/*" cd nextjs-messagebird-chat--typescript,--tailwind,--eslint: Recommended for type safety, styling, and code quality.--app: Uses the App Router.--src-dir: Places code inside asrc/directory.--use-npm: Uses npm package manager.--import-alias "@/*": Sets up path aliases.Install Dependencies:
next-auth: The current package name for Auth.js v5. (Installnext-auth@betaornext-auth@5.xto ensure v5 compatibility.)@auth/prisma-adapter: Adapter for Auth.js to use Prisma. (Note the @auth/ scope, updated from @next-auth/* in v5.)*prisma,@prisma/client: Prisma ORM and client library.messagebird: Official MessageBird Node.js SDK.bcryptjs: For password hashing (required for secure Credentials provider).prisma(dev dependency): For Prisma CLI commands (init, migrate, generate).@types/bcryptjs(dev dependency): TypeScript types for bcryptjs.Initialize Prisma:
prismadirectory with aschema.prismafile and a.envfile at your project root.postgresqltosqlite.Configure Environment Variables: Open the
.envfile created by Prisma (or create.env.localfor local overrides, which Git ignores). Add these variables:DATABASE_URL: Get this from your database provider (or set for local SQLite).AUTH_SECRET: Generate a strong secret usingopenssl rand -hex 32in your terminal. Crucial for session encryption.AUTH_URL: The base URL of your application. Essential for OAuth redirects and callbacks. Update this for production. (Note: Auth.js v5 uses AUTH_ prefix instead of NEXTAUTH_. Both are supported via automatic aliasing, but AUTH_ is preferred.)*MESSAGEBIRD_API_KEY: Find this in your MessageBird Dashboard under Developers > API access > Live API Key.MESSAGEBIRD_SENDER_ID: Your virtual mobile number purchased from MessageBird, in E.164 format (e.g.,+12223334444). This will be the "from" number for outbound messages.MESSAGEBIRD_WEBHOOK_SECRET: Crucial for security. Create a long, random, unpredictable string. Use this to verify that incoming webhook requests genuinely originate from MessageBird (or at least know the secret).Project Structure: Your
src/directory will containapp/for routes and components, potentiallylib/for utilities (like Prisma client instance, MessageBird client), andcomponents/for shared UI elements. Theprisma/directory holds your schema and migrations. Configuration files (.env,next.config.js,tsconfig.json) live at the root. This structure promotes separation of concerns.2. Database Schema and Data Layer (Prisma)
Define your database models for users, authentication, and messages.
Define Your Prisma Schema: Open
prisma/schema.prismaand define the models:User,Account,Session,VerificationToken).passwordHash(String, nullable) to store the hashed password for the Credentials provider.phoneNumber(String, unique) to theUsermodel.Messagemodel to store SMS details, includingdirection, sender/recipient numbers, and a relation to theUsermodel. TheuserIdis nullable and usesonDelete: SetNullso messages persist if you remove a user.Apply Schema to Your Database: Run this command to create or update your database tables based on the schema:
db pushis convenient. For production workflows with version control and team collaboration, usenpx prisma migrate dev(creates migration files) andnpx prisma migrate deploy(applies migrations in production). (Prisma 6.x best practice: Always use migrations for production databases to track schema changes.)Create Prisma Client Instance: Create a utility file to instantiate and export the Prisma client, ensuring only one instance exists.
// src/lib/prisma.ts import { PrismaClient } from '@prisma/client'; declare global { // eslint-disable-next-line no-var var prisma: PrismaClient | undefined; } export const prisma = global.prisma || new PrismaClient(); if (process.env.NODE_ENV !== 'production') global.prisma = prisma; export default prisma;3. Implementing Authentication (Auth.js)
Configure Auth.js to handle user sign-up, sign-in, and session management using the Credentials provider and Prisma adapter.
Configure Auth.js: Create your main Auth.js configuration file.
// src/auth.ts import NextAuth from 'next-auth'; import { PrismaAdapter } from '@auth/prisma-adapter'; import Credentials from 'next-auth/providers/credentials'; import prisma from '@/lib/prisma'; import { compare } from 'bcryptjs'; // Use compare from bcryptjs import type { User } from '@prisma/client'; // Import User type from Prisma import type { DefaultSession } from 'next-auth'; // Import DefaultSession import type { JWT as NextAuthJWT } from 'next-auth/jwt'; // Import JWT type export const { handlers, signIn, signOut, auth } = NextAuth({ adapter: PrismaAdapter(prisma), providers: [ Credentials({ name: 'Credentials', credentials: { email: { label: 'Email', type: 'email', placeholder: 'user@example.com' }, password: { label: 'Password', type: 'password' }, }, async authorize(credentials): Promise<User | null> { // Validate credentials input if (!credentials?.email || !credentials.password) { console.error('Authorize error: Missing email or password'); return null; } const email = credentials.email as string; const password = credentials.password as string; // Find user by email const user = await prisma.user.findUnique({ where: { email: email }, }); if (!user) { console.error(`No user found with email: ${email}`); return null; // User not found } // !! CRITICAL SECURITY !! // Verify the provided password against the stored hash. // The user MUST have a passwordHash field populated during registration. if (!user.passwordHash) { console.error(`User ${email} has no password hash set. Cannot authenticate via credentials.`); return null; // Cannot authenticate without a stored hash } const isValidPassword = await compare(password, user.passwordHash); if (!isValidPassword) { console.error(`Invalid password for user: ${email}`); return null; // Passwords do not match } console.log(`Credentials valid for user: ${email}`); // Return the user object if authentication succeeds // Ensure this object includes fields needed by callbacks (like id, phoneNumber) // The Prisma User type should align if you use the adapter correctly. const authorizedUser: User = { id: user.id, name: user.name, email: user.email, emailVerified: user.emailVerified, image: user.image, phoneNumber: user.phoneNumber, // Include phoneNumber passwordHash: null, // Ensure passwordHash never returns createdAt: user.createdAt, updatedAt: user.updatedAt, }; return authorizedUser; }, }), // Add other providers like Google, GitHub etc. if needed // GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! }), ], session: { strategy: 'jwt', // Recommended strategy, especially with Credentials }, callbacks: { // Include user ID and phone number in the JWT token async jwt({ token, user }) { if (user) { // user object is available on sign-in token.id = user.id; // Add custom properties from the User object returned by authorize/provider const prismaUser = user as User; // Cast to Prisma User type if (prismaUser.phoneNumber) { token.phoneNumber = prismaUser.phoneNumber; } } return token; }, // Include user ID and phone number in the session object from the JWT async session({ session, token }) { if (token && session.user) { session.user.id = token.id as string; if (token.phoneNumber) { session.user.phoneNumber = token.phoneNumber as string; } } return session; }, }, pages: { signIn: '/login', // Custom login page path // error: '/auth/error', // Optional: Custom error page // newUser: '/register' // Optional: Custom registration page }, // Add secret from .env secret: process.env.AUTH_SECRET, // Enable debug messages in development debug: process.env.NODE_ENV === 'development', }); // Add custom fields to the default Session User type & JWT type // Ensure these match the fields added in the jwt/session callbacks declare module 'next-auth' { interface Session { user: { id: string; phoneNumber?: string | null; // Add phoneNumber here } & DefaultSession['user']; // Keep default fields like name, email, image } // Extend the User type available in callbacks if needed (e.g., from authorize) // This should align with the Prisma User type or the object returned by authorize interface User { phoneNumber?: string | null; } } declare module 'next-auth/jwt' { interface JWT extends NextAuthJWT { // Extend the imported JWT type id: string; phoneNumber?: string | null; } }PrismaAdapter.Credentialsprovider with proper password verification usingbcryptjs.compare. Assumes apasswordHashfield exists on theUsermodel and is populated during registration.bcryptjs.hashbefore storing them in thepasswordHashfield. This guide doesn't detail the registration flow, but it's essential for the Credentials provider to function securely.jwtsession strategy.callbacksto add the user'sidandphoneNumberto the JWT and session object./login).Session,User, andJWTtypes to includephoneNumber.Create API Route Handlers: Set up the catch-all API route for Auth.js.
// src/app/api/auth/[...nextauth]/route.ts export { GET, POST } from '@/auth'; // export const runtime = "edge"; // Optional: Use Edge Runtime if preferred and compatibleCreate Your Login Page: Build a simple login page component.
// src/app/login/page.tsx 'use client'; import { useState } from 'react'; import { signIn } from 'next-auth/react'; import { useRouter, useSearchParams } from 'next/navigation'; export default function LoginPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false); const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get('callbackUrl') || '/dashboard'; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); setIsLoading(true); try { const result = await signIn('credentials', { redirect: false, // Handle redirect manually email, password, callbackUrl: callbackUrl }); setIsLoading(false); if (result?.error) { // Map common errors to user-friendly messages switch (result.error) { case 'CredentialsSignin': setError('Invalid email or password. Try again.'); break; default: setError('Login failed. Try again later.'); } console.error('Sign in error:', result.error); } else if (result?.ok && result.url) { // Sign in successful, use the returned URL (includes callbackUrl) router.push(result.url); } else if (result?.ok) { // Fallback if URL is missing but sign-in was ok router.push(callbackUrl); } else { setError('An unexpected error occurred during sign in.'); } } catch (err) { setIsLoading(false); console.error('Sign in exception:', err); setError('An error occurred. Try again later.'); } }; return ( <div className="max-w-md mx-auto mt-10 p-6 border border-gray-300 rounded-lg shadow-md"> <h1 className="text-2xl font-bold mb-6 text-center">Login</h1> <form onSubmit={handleSubmit}> {error && <p className="mb-4 text-red-600 text-sm text-center">{error}</p>} <div className="mb-4"> <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">Email:</label> <input type="email" id="email" value={email} onChange={(e) => setEmail(e.target.value)} required disabled={isLoading} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="user@example.com" /> </div> <div className="mb-6"> <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">Password:</label> <input type="password" id="password" value={password} onChange={(e) => setPassword(e.target.value)} required disabled={isLoading} className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" /> </div> <button type="submit" disabled={isLoading} className="w-full py-2 px-4 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-md shadow-sm disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" > {isLoading ? 'Logging in…' : 'Login'} </button> </form> </div> ); }signInfunction fromnext-auth/react.credentialsprovider, manages error display, and redirects usingnext/navigation.passwordHashfield.Wrap Your App in SessionProvider: To use client-side hooks like
useSession, wrap your application layout.// src/app/providers.tsx (Create this file) 'use client'; import { SessionProvider } from 'next-auth/react'; import React from 'react'; interface ProvidersProps { children: React.ReactNode; } export function Providers({ children }: ProvidersProps) { return <SessionProvider>{children}</SessionProvider>; }// src/app/layout.tsx import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; // Includes Tailwind base styles import { Providers } from './providers'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: 'Next.js MessageBird Chat', description: 'Two-way SMS with Auth.js and MessageBird', }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={`${inter.className} bg-gray-50`}> <Providers> <main>{children}</main> </Providers> </body> </html> ); }Access Session Data:
auth()helper exported fromsrc/auth.ts.// Example in a Server Component (e.g., src/app/dashboard/page.tsx) import { auth } from '@/auth'; import { redirect } from 'next/navigation'; export default async function DashboardPage() { const session = await auth(); // Get session on the server if (!session?.user) { // Redirect to login, preserving the intended destination redirect(`/login?callbackUrl=/dashboard`); } return ( <div className="p-4 md:p-8"> <h1 className="text-2xl font-semibold mb-4">Dashboard</h1> <p className="mb-2">Welcome, {session.user.name ?? session.user.email ?? 'User'}!</p> <p className="mb-2">Your Email: {session.user.email}</p> <p className="mb-4">Your Phone Number: {session.user.phoneNumber ?? 'Not set'}</p> {/* Add Chat Interface Component Here (conditionally based on phone number) */} </div> ); }useSessionhook fromnext-auth/react.// Example in a Client Component (e.g., src/components/UserProfile.tsx) 'use client'; import { useSession, signOut } from 'next-auth/react'; import Link from 'next/link'; import Image from 'next/image'; export function UserProfile() { const { data: session, status } = useSession(); // status: 'loading' | 'authenticated' | 'unauthenticated' if (status === 'loading') { return <p className="text-sm text-gray-500">Loading session…</p>; } if (status === 'unauthenticated') { return ( <Link href="/login" className="text-sm text-blue-600 hover:underline"> Sign In </Link> ); } // status === 'authenticated' return ( <div className="flex items-center space-x-4 p-2 bg-white rounded shadow"> {session?.user?.image && ( <Image src={session.user.image} alt="User avatar" width={32} height={32} className="w-8 h-8 rounded-full" /> )} <div className="text-sm"> <p>Signed in as <span className="font-medium">{session?.user?.email}</span></p> <p>Phone: {session?.user?.phoneNumber ?? 'N/A'}</p> </div> <button onClick={() => signOut({ callbackUrl: '/' })} className="ml-auto px-3 py-1 text-sm bg-red-500 hover:bg-red-600 text-white rounded" > Sign Out </button> </div> ); }4. Integrating MessageBird for Inbound SMS (Webhooks)
Set up your webhook endpoint to receive incoming SMS messages from MessageBird.
Create MessageBird Client Instance: Similar to Prisma, create a utility for your MessageBird client.
// src/lib/messagebird.ts import initMessageBird from 'messagebird'; const apiKey = process.env.MESSAGEBIRD_API_KEY; if (!apiKey) { // Log a warning in development, but potentially throw in production if critical if (process.env.NODE_ENV === 'development') { console.warn('MessageBird API Key (MESSAGEBIRD_API_KEY) not found in environment variables. MessageBird functionality will be disabled.'); } else { // In production, this might be a fatal error depending on your requirements console.error('CRITICAL: Missing MESSAGEBIRD_API_KEY environment variable.'); // throw new Error('Missing MESSAGEBIRD_API_KEY environment variable'); } } // Initialize only if apiKey exists, otherwise export null export const messagebird = apiKey ? initMessageBird(apiKey as string) : null; // Export a function to get the client to handle the null case more gracefully elsewhere export const getMessageBirdClient = () => { if (!messagebird) { console.error("Attempted to use MessageBird client, but it's not initialized (missing API key)."); // throw new Error("MessageBird client is not available."); } return messagebird; } export default messagebird;Create Your Webhook API Route: This route listens for POST requests from MessageBird's Flow Builder.
// src/app/api/webhooks/messagebird/route.ts import { NextRequest, NextResponse } from 'next/server'; import prisma from '@/lib/prisma'; import crypto from 'crypto'; // For constant-time comparison // Secure Webhook Secret Verification (Constant Time Comparison) // SECURITY BEST PRACTICE: Always verify webhook authenticity to prevent unauthorized access function verifyWebhookSecret(request: NextRequest): boolean { const providedSecret = request.nextUrl.searchParams.get('secret'); const expectedSecret = process.env.MESSAGEBIRD_WEBHOOK_SECRET; if (!expectedSecret) { console.error('[Webhook Security] MESSAGEBIRD_WEBHOOK_SECRET is not set in environment. Cannot verify webhook.'); return false; // Fail safe } if (!providedSecret) { console.warn('[Webhook Security] Webhook request missing required "secret" query parameter.'); return false; } // Use crypto.timingSafeEqual for constant-time comparison to prevent timing attacks try { const providedBuffer = Buffer.from(providedSecret); const expectedBuffer = Buffer.from(expectedSecret); // Ensure buffers are the same length before comparing if (providedBuffer.length !== expectedBuffer.length) { console.warn('[Webhook Security] Provided secret length mismatch.'); // Still perform a comparison with the expected buffer to ensure constant time crypto.timingSafeEqual(expectedBuffer, expectedBuffer); return false; } const isEqual = crypto.timingSafeEqual(providedBuffer, expectedBuffer); if (!isEqual) { console.warn('[Webhook Security] Invalid webhook secret provided.'); } return isEqual; } catch (error) { console.error('[Webhook Security] Error during secret comparison:', error); return false; } } // Define the expected structure of the incoming webhook payload // Adjust based on what your MessageBird Flow Builder actually sends interface MessageBirdWebhookPayload { originator: string; // Sender's phone number (E.164) recipient: string; // Your MessageBird virtual number (E.164) payload: string; // The SMS message content // Add other fields you might send from Flow Builder messageId?: string; // Optional: MessageBird's internal ID receivedAt?: string; // Optional: Timestamp from MessageBird } export async function POST(request: NextRequest) { console.log('[Webhook] Received request'); // 1. Verify the webhook secret if (!verifyWebhookSecret(request)) { console.error('[Webhook] Failed webhook secret verification.'); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } console.log('[Webhook] Secret verified successfully.'); try { // 2. Parse the incoming JSON payload const payload: MessageBirdWebhookPayload = await request.json(); console.log('[Webhook] Payload received:', payload); // Basic validation of required fields if (!payload.originator || !payload.recipient || !payload.payload) { console.error('[Webhook] Invalid payload structure. Missing required fields.'); return NextResponse.json({ error: 'Bad Request: Missing required fields' }, { status: 400 }); } const senderNumber = payload.originator; // The external user's number const virtualNumber = payload.recipient; // Your app's number const content = payload.payload; const messageBirdId = payload.messageId; // Optional // 3. Find the corresponding user in your database // Use the SENDER's number (originator) to find the user who should receive this message in the app // This assumes you store the user's phone number in E.164 format and it's unique const user = await prisma.user.findUnique({ where: { phoneNumber: senderNumber }, }); if (!user) { // Decide how to handle messages from unknown numbers // Option 1: Ignore and log console.warn(`[Webhook] Received message from unknown number: ${senderNumber}. Ignoring.`); // Option 2: Store message without associating user (set userId to null) // await prisma.message.create({ … data with userId: null … }); // Option 3: Trigger an alert or specific flow return NextResponse.json({ message: 'User not found for this number' }, { status: 200 }); // Acknowledge receipt but indicate no user match } console.log(`[Webhook] Found user ${user.id} associated with number ${senderNumber}`); // 4. Store the inbound message in your database const newMessage = await prisma.message.create({ data: { content: content, direction: 'inbound', senderNumber: senderNumber, // From the external user recipientNumber: virtualNumber, // To your virtual number messageBirdId: messageBirdId, // Optional MessageBird ID userId: user.id, // Associate with the found user }, }); console.log(`[Webhook] Stored inbound message ${newMessage.id} for user ${user.id}`); // 5. (Optional) Trigger real-time updates (e.g., via WebSockets/Pusher/Server-Sent Events) // This would notify the user's frontend that a new message has arrived. // Example: await triggerRealtimeUpdate(user.id, newMessage); // 6. Respond to MessageBird to acknowledge receipt // A 2xx status code tells MessageBird the webhook was received successfully. return NextResponse.json({ message: 'Webhook received successfully' }, { status: 200 }); } catch (error) { console.error('[Webhook] Error processing webhook:', error); // Handle JSON parsing errors specifically if (error instanceof SyntaxError) { return NextResponse.json({ error: 'Bad Request: Invalid JSON payload' }, { status: 400 }); } // Generic internal server error for other issues return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); } }