code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

MessageBird Next.js Two-Way SMS Tutorial: Auth.js + Inbound Webhooks

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.

MessageBird Next.js Two-Way SMS Tutorial: Auth.js + Inbound Webhooks

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:

  1. Users authenticate using email/password (via Auth.js Credentials provider).
  2. Authenticated users have a phone number associated with their profile (stored via Prisma).
  3. Incoming SMS messages sent to a specific MessageBird virtual number route to your application via webhooks.
  4. Your application identifies the corresponding user based on the sender's phone number (originator).
  5. Inbound messages store in your database and display to the relevant user within the app.
  6. 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:

mermaid
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.

  1. Create Your Next.js App: Open your terminal and run:

    bash
    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 a src/ directory.
    • --use-npm: Uses npm package manager.
    • --import-alias "@/*": Sets up path aliases.
  2. Install Dependencies:

    bash
    npm install next-auth @auth/prisma-adapter prisma @prisma/client messagebird bcryptjs
    npm install -D prisma @types/bcryptjs
    • 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.
  3. 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.
  4. Configure Environment Variables: Open the .env file created by Prisma (or create .env.local for local overrides, which Git ignores). Add these variables:

    dotenv
    # .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).
  5. 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.

  1. 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.
  2. 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.)
  3. Create Prisma Client Instance: Create a utility file to instantiate and export the Prisma client, ensuring only one instance exists.

    typescript
    // 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.

  1. Configure Auth.js: Create your main Auth.js configuration file.

    typescript
    // 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;
       }
     }
    • 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.
  2. Create API Route Handlers: Set up the catch-all API route for Auth.js.

    typescript
    // src/app/api/auth/[...nextauth]/route.ts
    export { GET, POST } from '@/auth';
    // export const runtime = "edge"; // Optional: Use Edge Runtime if preferred and compatible
  3. Create Your Login Page: Build a simple login page component.

    typescript
    // 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>
      );
    }
    • Uses the signIn function from next-auth/react.
    • 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.
  4. Wrap Your App in SessionProvider: To use client-side hooks like useSession, wrap your application layout.

    typescript
    // 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>;
    }
    typescript
    // 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>
      );
    }
  5. Access Session Data:

    • Server Components / API Routes / RSC: Use the auth() helper exported from src/auth.ts.
      typescript
      // 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>
        );
      }
    • Client Components: Use the useSession hook from next-auth/react.
      typescript
      // 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.

  1. Create MessageBird Client Instance: Similar to Prisma, create a utility for your MessageBird client.

    typescript
    // 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;
  2. Create Your Webhook API Route: This route listens for POST requests from MessageBird's Flow Builder.

    typescript
    // 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 });
        }
    }

Frequently Asked Questions

How to set up two-way SMS in Next.js?

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.

What is MessageBird used for in this project?

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.

Why use Auth.js with MessageBird integration?

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.

When should I use Ngrok with MessageBird?

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.

Can I use a database other than PostgreSQL?

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.

How to handle inbound SMS messages in Next.js?

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.

What is the purpose of the MessageBird webhook secret?

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.

How to send outbound SMS replies with MessageBird?

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.

What Next.js version is required for this tutorial?

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.

How to associate SMS messages with users?

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.

What is Prisma, and why is it used?

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.

How to configure Auth.js Credentials provider securely?

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**.

What is the purpose of the `AUTH_SECRET` environment variable?

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.