code examples
code examples
Receive Inbound SMS with Sinch, Next.js, and NextAuth: Two-Way Messaging Tutorial
Learn how to receive and process inbound SMS messages in Next.js using Sinch webhooks with NextAuth authentication. Complete guide with HMAC-SHA256 verification, E.164 phone validation, and production deployment.
Build Two-Way SMS with Sinch, Next.js, and NextAuth
Learn how to build a Next.js application that receives and processes inbound SMS messages using Sinch webhooks with NextAuth authentication. This comprehensive tutorial covers webhook security with HMAC-SHA256 verification, E.164 phone number validation, user authentication, and production deployment.
You'll create a secure web application where authenticated users receive SMS messages sent to a dedicated Sinch phone number, automatically linked to their account via their registered phone number.
What you'll build:
- NextAuth.js authentication system with email/password and phone numbers
- Secure Sinch webhook endpoint with HMAC-SHA256 signature verification
- E.164 phone number validation and user-message association
- Inbound SMS processing with PostgreSQL database storage
- Protected user dashboard displaying received messages
- Production-ready deployment configuration for Vercel or similar platforms
Table of Contents
- Project Overview and Goals
- Set Up Your Next.js Project
- Implement User Authentication
- Build Sinch Webhook Handler
- Create Dashboard and Pages
- Production Deployment
- Extension Ideas
- Frequently Asked Questions
Project Overview and Goals
Your application features:
- User authentication – Email/password credentials via NextAuth.js (Auth.js v5)
- Phone number association – E.164-formatted phone numbers linked to user profiles
- Inbound SMS webhooks – Receives SMS messages sent to your Sinch number
- Signature verification – HMAC-SHA256 authentication prevents unauthorized requests
- User identification – Matches sender phone numbers to registered users
- Message storage – Stores SMS content in PostgreSQL, linked to users
- Extensible foundation – Ready for automated replies and two-way conversations
Why receive inbound SMS with Next.js and Sinch?
Traditional web applications can't directly receive SMS messages from users. This guide shows you how to programmatically receive and process SMS messages through Sinch webhooks, linking them to authenticated user accounts within a modern Next.js framework. Use this foundation for:
- Healthcare applications: Appointment confirmations and prescription reminders
- Delivery services: Real-time order updates and driver communication
- Customer support systems: Instant query responses and issue tracking
- Two-factor authentication: SMS-based verification flows
- Notification systems: Critical alerts and time-sensitive communications
Technologies Used:
- Next.js (v15.5): React framework for building server-rendered applications (using App Router). As of October 2025, Next.js 15.5 is the latest version with React 19 support and Turbopack builds.
- NextAuth.js (v5 Beta / Auth.js): Authentication library for Next.js, simplifying login, logout, and session management. (Disclaimer: This guide uses
next-auth@beta(v5.0.0-beta.25), now branded as Auth.js. While feature-complete and widely used, beta versions may contain bugs or breaking changes. For maximum stability in production, consider using the latest stable v4 release or carefully test v5 before deploying. NextAuth v5 requires Next.js 14.0 or higher.) - Sinch SMS API: Communications Platform as a Service (CPaaS) provider for SMS functionality (specifically receiving inbound messages via webhooks with HMAC-SHA256 signature verification).
- PostgreSQL: Relational database for storing user and message data.
- Prisma: Next-generation ORM for database access and migrations with support for optimized indexing strategies.
- TypeScript: For static typing and improved code quality.
- bcryptjs: For securely hashing user passwords (10 salt rounds recommended for production).
- Zod: For data validation (credentials, webhook payloads, E.164 phone format).
- Tailwind CSS: For styling (optional, included with
create-next-app).
System Architecture:
graph TD
A[User Browser] -- HTTPS --> B(Next.js App / Vercel);
B -- Login Request --> C{NextAuth.js Middleware};
C -- Verify Credentials --> D[Database (PostgreSQL)];
D -- User Record --> C;
C -- Session Cookie --> A;
E[User's Phone] -- Sends SMS --> F(Sinch Platform);
F -- Inbound SMS Webhook (POST) --> G[Next.js API Route (/api/webhooks/sinch)];
G -- Verify Signature --> F;
G -- Lookup User by Phone (E.164) --> D;
G -- Store Message --> D;
D -- User/Message Data --> G;
G -- 200 OK --> F;
style G fill:#f9f,stroke:#333,stroke-width:2px
style F fill:#ccf,stroke:#333,stroke-width:2px
style D fill:#ff9,stroke:#333,stroke-width:2pxPrerequisites:
- Node.js (v18 or later required for Next.js 15; v20 LTS recommended for optimal compatibility) and npm/pnpm/yarn.
- Access to a PostgreSQL database instance (v12+ recommended).
- A Sinch account with API credentials and a provisioned SMS-enabled phone number.
- Basic understanding of React, TypeScript, and REST APIs.
opensslcommand line tool for generating secrets. (Alternatives for Windows include Git Bash, Windows Subsystem for Linux (WSL), or online generation tools – use trusted tools for security-sensitive secrets).- A tool for testing webhooks locally (e.g., ngrok, Vercel CLI, or Cloudflare Tunnel). Production alternatives include public IP addresses or other tunneling services.
Final Outcome:
A deployed Next.js application where users can register and login with phone number authentication. Incoming SMS messages sent to the configured Sinch number are securely received, verified via HMAC-SHA256, associated with the correct user based on their registered phone number (using consistent E.164 format), and stored in the PostgreSQL database.
1. Set Up Your Next.js Project
Initialize your Next.js application and install the necessary dependencies for receiving inbound SMS.
1.1 Create Next.js Application
Open your terminal and create a new Next.js project:
npx create-next-app@latest sinch-inbound-app --typescript --eslint --tailwind --src-dir --app --use-npm
cd sinch-inbound-appEnable TypeScript, ESLint, Tailwind CSS, the src/ directory, and the App Router when prompted. These options create a production-ready project structure with modern Next.js features.
1.2 Install Required Dependencies
Install authentication, database, and Sinch-related packages:
npm install next-auth@beta @next-auth/prisma-adapter prisma @prisma/client pg bcryptjs @types/bcryptjs zod @sinch/sdk-core
npm install --save-dev prismaPackage explanations:
next-auth@beta(v5.0.0-beta.25) – Authentication library, now branded as Auth.js@next-auth/prisma-adapter– Connects NextAuth to your Prisma databaseprismaand@prisma/client– ORM for type-safe database accesspg– PostgreSQL driver required by Prismabcryptjsand@types/bcryptjs– Secure password hashing (10 salt rounds)zod– Runtime validation for credentials, webhooks, and phone numbers@sinch/sdk-core– Optional Sinch SDK for sending replies (not required for receiving)
1.3 Initialize Prisma
Set up Prisma in your project:
npx prisma initThis creates a prisma directory with schema.prisma and a .env file at your project root.
1.4 Configure Environment Variables
Open the .env file created by Prisma and add these configuration variables. Never commit this file to version control – it contains sensitive credentials.
# .env
# Database Connection
# Replace with your actual PostgreSQL credentials
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
# NextAuth Secret
# Generate using: openssl rand -base64 32
# This secret encrypts session cookies and JWT tokens
AUTH_SECRET=YOUR_AUTH_SECRET_GENERATED_WITH_OPENSSL
# Sinch API Credentials
# Get these from your Sinch Dashboard: https://dashboard.sinch.com
# Required if you plan to send outbound SMS messages
SINCH_PROJECT_ID=YOUR_SINCH_PROJECT_ID
SINCH_APP_KEY=YOUR_SINCH_APP_KEY
SINCH_APP_SECRET=YOUR_SINCH_APP_SECRET
# Sinch Webhook Secret
# Create a strong secret YOU define: openssl rand -base64 32
# Configure this same secret in the Sinch portal for webhook signature verification
SINCH_WEBHOOK_SECRET=YOUR_STRONG_RANDOM_WEBHOOK_SECRET
# Application URL
# Development: http://localhost:3000
# Production: https://yourdomain.com
# Required for NextAuth redirects and callbacks
NEXTAUTH_URL=http://localhost:3000Security requirements:
AUTH_SECRET– Generate withopenssl rand -base64 32. Never use a weak or hardcoded value. This protects all user sessions.SINCH_WEBHOOK_SECRET– Generate withopenssl rand -base64 32. You create this secret and configure it in both your code and the Sinch dashboard. Sinch uses it to sign webhook requests.DATABASE_URL– Use SSL in production: add?sslmode=requireto your connection string.NEXTAUTH_URL– Must match your deployment URL exactly, including protocol (http:// or https://).
1.5 Define Database Schema
Open prisma/schema.prisma and define your data models. Store phone numbers in E.164 format (+15551234567) for consistent international number handling.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// NextAuth required models for session management
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)
}
// User model with phone number for SMS association
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
password String? // Hashed with bcryptjs (10 salt rounds)
phoneNumber String? @unique // E.164 format (e.g., +15551234567)
image String?
accounts Account[]
sessions Session[]
messages Message[] // All SMS messages received by this user
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// SMS message storage with user association
model Message {
id String @id @default(cuid())
sinchMessageId String @unique // Sinch's unique message identifier
fromNumber String // Sender's phone (E.164 format from Sinch)
toNumber String // Your Sinch number (E.164 format)
body String? @db.Text // Message text content
direction String // 'inbound' or 'outbound'
timestamp DateTime // When Sinch received the message
userId String? // Link to User (null if sender not registered)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) // When we stored it
// Explicit indexes for query optimization (B-tree indexes for range/equality operations)
// These indexes significantly improve query performance on large datasets (thousands+ records)
@@index([userId]) // Optimizes: WHERE userId = ? and JOIN operations
@@index([fromNumber]) // Optimizes: WHERE fromNumber = ? for sender lookups
@@index([timestamp]) // Optimizes: ORDER BY timestamp and date range queries
}Key design decisions:
- E.164 phone format – International standard ensures consistent number matching
- Unique constraints – Prevent duplicate users and messages
- Explicit indexes – B-tree indexes speed up queries by 10-100× on large datasets
- Optional user link – Messages stored even if sender isn't registered
- Cascade deletes – Removing a user also removes their sessions (not their messages)
1.6 Apply Database Migrations
Sync your Prisma schema with your PostgreSQL database:
npx prisma db pushThis command creates all tables, indexes, and constraints defined in your schema. Use db push for rapid development iteration.
For production deployments, use Prisma's migration system for version control:
# Create a migration file
npx prisma migrate dev --name init
# Deploy migrations to production
npx prisma migrate deployMigrations provide:
- Version-controlled schema changes
- Rollback capabilities
- Safe multi-developer workflows
- Deployment history tracking
1.7 Project Structure Overview
Your src directory structure:
src/
├── app/
│ ├── api/ # API routes (Webhook handler here)
│ │ └── webhooks/
│ │ └── sinch/
│ │ └── route.ts # Sinch webhook handler
│ ├── (auth)/ # Authentication related pages (optional grouping)
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── signup/
│ │ └── page.tsx
│ ├── dashboard/ # Protected area
│ │ └── page.tsx
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ └── globals.css
├── components/ # Reusable UI components
│ ├── auth/
│ │ ├── LoginForm.tsx
│ │ └── SignUpForm.tsx
│ └── ui/ # UI primitives (Button, Input, Label - needs creation/install)
│ ├── button.tsx
│ ├── input.tsx
│ └── label.tsx
├── lib/ # Helper functions, Prisma client, actions
│ ├── prisma.ts # Prisma client instance
│ ├── actions/ # Server Actions (auth, potentially messaging)
│ │ └── auth.ts
│ └── utils.ts # Utility functions (e.g., webhook verification)
├── auth.config.ts # NextAuth base configuration
├── auth.ts # NextAuth main configuration & handlers
├── middleware.ts # NextAuth Middleware for route protection
└── ... (other config files)2. Implement User Authentication
Set up NextAuth.js (Auth.js v5) for secure user registration and login with email, password, and phone number validation.
2.1 Create Prisma Client Instance
Create a singleton Prisma client to prevent connection exhaustion in development:
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
// Configure logging based on environment
const prisma =
global.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn'] // Verbose logging in development
: ['error'], // Only errors in production
});
// Prevent multiple instances in development hot-reload
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
}
export default prisma;Why use a global variable? Next.js hot-reloading in development creates new module instances on each save. Without the global variable, you'd create dozens of database connections, eventually exhausting your connection pool.
2.2 NextAuth Base Configuration (auth.config.ts)
Define basic configurations like custom pages and the authorization callback used by the middleware.
// src/auth.config.ts
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
// trustHost: true, // Consider uncommenting if deploying behind a proxy and facing issues, but understand security implications.
pages: {
signIn: '/login', // Redirect users to /login if they need to authenticate
// error: '/auth/error', // Optional: Custom error page
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
const isOnAuthPage = nextUrl.pathname === '/login' || nextUrl.pathname === '/signup';
if (isOnDashboard) {
if (isLoggedIn) return true; // Allow access if logged in
return false; // Redirect unauthenticated users to login page
} else if (isLoggedIn && isOnAuthPage) {
// If logged in and trying to access login/signup, redirect to dashboard
const dashboardUrlBase = process.env.NEXTAUTH_URL;
if (dashboardUrlBase) {
// Prefer absolute redirect if NEXTAUTH_URL is set
console.log(`Redirecting logged-in user from ${nextUrl.pathname} to dashboard.`);
return Response.redirect(new URL('/dashboard', dashboardUrlBase));
} else {
// Fallback to relative redirect if NEXTAUTH_URL is missing (less ideal)
console.warn('NEXTAUTH_URL is not set. Attempting relative redirect to /dashboard.');
// Note: Relative redirects in middleware might not work as expected in all environments.
// Consider ensuring NEXTAUTH_URL is always set.
// For this example, we attempt it, but it might require returning false to trigger default redirect.
return Response.redirect(new URL('/dashboard', nextUrl.origin)); // Try constructing absolute from origin
}
}
// Allow access to all other pages (home, public routes, auth pages if not logged in)
return true;
},
// JWT and Session callbacks are defined in auth.ts
},
providers: [
// Provider definitions will go in auth.ts
],
} satisfies NextAuthConfig;2.3 NextAuth Main Configuration (auth.ts)
This file includes the provider logic (Credentials), database interactions, and password handling.
// src/auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { authConfig } from './auth.config';
import prisma from '@/lib/prisma';
import type { User } from '@prisma/client';
// Helper function to find user by email
async function getUserByEmail(email: string): Promise<User | null> {
try {
// In production, replace console.log with structured logging
// logger.info({ email }, 'Attempting to fetch user by email');
return await prisma.user.findUnique({ where: { email } });
} catch (error) {
// Log error to a monitoring service in production
console.error('Failed to fetch user by email:', error);
// logger.error({ email, error }, 'Database error while fetching user');
throw new Error('Database error while fetching user.'); // Re-throw for NextAuth to handle
}
}
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
...authConfig, // Spread the base config
adapter: PrismaAdapter(prisma), // Use Prisma for session, account, etc. storage
session: { strategy: 'jwt' }, // Use JWT strategy for sessions
providers: [
Credentials({
async authorize(credentials) {
// 1. Validate credentials using Zod
const parsedCredentials = z
.object({
email: z.string().email({ message: 'Invalid email format.' }),
password: z.string().min(6, { message: 'Password must be at least 6 characters.' }),
})
.safeParse(credentials);
if (!parsedCredentials.success) {
console.log('Invalid credentials format:', parsedCredentials.error.flatten().fieldErrors);
return null; // Returning null triggers CredentialsSignin error type
}
const { email, password } = parsedCredentials.data;
// 2. Find user in the database
const user = await getUserByEmail(email);
if (!user || !user.password) {
console.log(`No user found for email: ${email} or user has no password set.`);
return null; // User not found or password not set
}
// 3. Compare passwords
const passwordsMatch = await bcrypt.compare(password, user.password);
if (passwordsMatch) {
console.log(`User ${email} authenticated successfully.`);
// Return the user object required by NextAuth
// Ensure the returned object matches NextAuth's expected User shape for the session/JWT
return { id: user.id, email: user.email, name: user.name, image: user.image };
}
// 4. If passwords don't match
console.log(`Password mismatch for user: ${email}`);
return null; // Invalid password
},
}),
// Add other providers like Google, GitHub, etc. here if needed
],
callbacks: {
...authConfig.callbacks, // Include callbacks from authConfig (like `authorized`)
async jwt({ token, user }) {
// Add custom claims to JWT (e.g., user ID) after sign in
if (user) {
token.id = user.id;
// You could add roles or other data here if needed
// token.role = user.role; // Assuming user object has role
}
return token;
},
async session({ session, token }) {
// Add custom data to the session object from the JWT
if (token && session.user) {
session.user.id = token.id as string; // Add user ID to session.user
// session.user.role = token.role; // Add role if present in token
}
return session;
},
}
});2.4 Middleware for Route Protection
Create the middleware file to protect routes based on authentication status using the authorized callback defined in auth.config.ts.
// src/middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
// Initialize NextAuth using the configuration that contains the 'authorized' callback
export default NextAuth(authConfig).auth;
export const config = {
// Matcher specifies routes where the middleware should run.
// It uses a negative lookahead to exclude API routes, static files, images, etc.
// Adjust the matcher if you have other top-level routes to exclude or include.
// Example: ['/dashboard/:path*', '/settings']
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$|favicon.ico).*)'],
};2.5 Server Actions for Auth (actions/auth.ts)
Create Server Actions for handling sign-up and login form submissions.
// src/lib/actions/auth.ts
'use server';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { AuthError } from 'next-auth';
import { signIn, signOut } from '@/auth'; // Import signIn/signOut from auth.ts
import prisma from '@/lib/prisma';
// --- Login Action ---
const LoginSchema = z.object({
email: z.string().email({ message: 'Please enter a valid email.' }),
password: z.string().min(1, { message: 'Password is required.' }), // Basic check, NextAuth authorize does deeper check
});
export type LoginState = {
errors?: {
email?: string[];
password?: string[];
credentials?: string[]; // For general sign-in errors
};
message?: string | null;
};
export async function authenticate(
prevState: LoginState | undefined,
formData: FormData,
): Promise<LoginState> {
const validatedFields = LoginSchema.safeParse(
Object.fromEntries(formData.entries()),
);
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Invalid fields. Failed to Login.',
};
}
const { email, password } = validatedFields.data;
try {
// In production, replace console.log with structured logging
console.log(`Attempting sign in for user: ${email}`);
await signIn('credentials', {
email,
password,
redirect: false, // Handle redirect via middleware or explicitly on success return
});
// If signIn succeeds without throwing, middleware will likely handle redirect.
console.log(`Sign in successful for: ${email}. Redirect should occur via middleware.`);
// Return success state, though middleware might redirect before this is processed client-side
return { message: 'Login successful! Redirecting…' };
} catch (error) {
if (error instanceof AuthError) {
// Log specific AuthError types for debugging
switch (error.type) {
case 'CredentialsSignin':
console.error('CredentialsSignin error:', error.cause?.err?.message);
return { message: 'Invalid email or password.', errors: { credentials: ['Invalid email or password.'] } };
case 'CallbackRouteError':
console.error('CallbackRouteError:', error.cause?.err?.message);
return { message: `Authentication callback failed: ${error.cause?.err?.message || 'Unknown callback error'}`, errors: { credentials: ['Authentication callback failed.'] } };
default:
console.error('Unknown NextAuth error:', error);
return { message: 'Something went wrong during authentication.', errors: { credentials: ['An unexpected error occurred.'] } };
}
}
// Handle non-AuthError exceptions (e.g., database connection issues caught before signIn is called)
console.error('Non-AuthError during authenticate:', error);
// Consider re-throwing if it's a critical, unexpected error that should halt execution.
// For now, return a generic server error message.
// throw error; // Uncomment if critical errors should propagate and potentially crash the request
return { message: 'An unexpected server error occurred during login.', errors: { credentials: ['Server error during login.'] } };
}
}
// --- Sign Up Action ---
const SignUpSchema = z.object({
email: z.string().email({ message: 'Please enter a valid email.' }),
password: z.string().min(6, { message: 'Password must be at least 6 characters.' }),
name: z.string().min(1, { message: 'Name is required.' }),
// E.164 format validation: + followed by 1-15 digits, first digit 1-9 (country codes don't start with 0)
// Recommended pattern from ITU E.164 specification
phoneNumber: z.string()
.min(8, { message: 'Please enter a valid phone number (e.g., +15551234567).' })
.regex(/^\+[1-9]\d{1,14}$/, { message: "Invalid phone number format. Must be E.164 format: +[country code][number] (e.g., +15551234567). Total 2-15 digits." }),
});
export type SignUpState = {
errors?: {
email?: string[];
password?: string[];
name?: string[];
phoneNumber?: string[];
server?: string[]; // For general server/database errors
};
message?: string | null;
success?: boolean;
};
export async function registerUser(
prevState: SignUpState | undefined,
formData: FormData,
): Promise<SignUpState> {
const validatedFields = SignUpSchema.safeParse(
Object.fromEntries(formData.entries()),
);
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Invalid fields. Failed to Sign Up.',
success: false,
};
}
const { email, password, name, phoneNumber } = validatedFields.data;
// Ensure phone number starts with '+' for consistent E.164 storage, if not already present
const formattedPhoneNumber = phoneNumber.startsWith('+') ? phoneNumber : `+${phoneNumber}`;
// Check if user already exists
try {
const existingUserByEmail = await prisma.user.findUnique({ where: { email } });
if (existingUserByEmail) {
return { message: 'Email already in use.', errors: { email: ['Email already exists.'] }, success: false };
}
const existingUserByPhone = await prisma.user.findUnique({ where: { phoneNumber: formattedPhoneNumber } });
if (existingUserByPhone) {
return { message: 'Phone number already in use.', errors: { phoneNumber: ['Phone number already exists.'] }, success: false };
}
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10); // Salt rounds = 10
// Create the user in the database
await prisma.user.create({
data: {
email,
password: hashedPassword,
name,
phoneNumber: formattedPhoneNumber, // Store consistently formatted number
},
});
// In production, replace console.log with structured logging
console.log(`User registered successfully: ${email}`);
// Optionally: Automatically sign in the user after registration
// await signIn('credentials', { email, password, redirect: false });
return { message: 'Registration successful! Please log in.', success: true };
} catch (error) {
console.error('Registration error:', error);
// Log to monitoring service in production
// Check for specific Prisma unique constraint violation errors (race condition safety)
if (error instanceof Error && 'code' in error && (error as any).code === 'P2002') {
const target = (error as any).meta?.target;
if (target?.includes('email')) {
return { message: 'Email already in use.', errors: { email: ['Email already exists.'] }, success: false };
}
if (target?.includes('phoneNumber')) {
return { message: 'Phone number already in use.', errors: { phoneNumber: ['Phone number already exists.'] }, success: false };
}
}
return { message: 'Database error: Failed to register user.', errors: { server: ['Could not create user account.'] }, success: false };
}
}
// --- Sign Out Action ---
export async function logout() {
try {
// In production, replace console.log with structured logging
console.log("Attempting user sign out.");
await signOut({ redirectTo: '/login' }); // Redirect to login after sign out
} catch (error) {
console.error("Sign out error:", error);
// Log error to monitoring service in production
// Handle potential errors during sign out, though typically straightforward
// Maybe redirect to an error page or show a message if signOut itself fails
// For now, the redirect might still happen client-side even if server action errors.
}
}2.6 UI Components (Login and Sign Up Forms)
Create basic form components. Note: These examples assume you have basic Button, Input, and Label components. Install shadcn/ui for pre-built components:
npx shadcn-ui@latest init
npx shadcn-ui@latest add button input label// src/components/ui/button.tsx, input.tsx, label.tsx
// ... Implement or install basic UI components ...
// src/components/auth/LoginForm.tsx
'use client';
import { useActionState } from 'react';
import { authenticate, LoginState } from '@/lib/actions/auth';
import { Button } from '@/components/ui/button'; // Adjust path if needed
import { Input } from '@/components/ui/input'; // Adjust path if needed
import { Label } from '@/components/ui/label'; // Adjust path if needed
import Link from 'next/link'; // Use Next.js Link for navigation
export default function LoginForm() {
const initialState: LoginState | undefined = undefined;
const [state, formAction, isPending] = useActionState(authenticate, initialState);
return (
<form action={formAction} className="space-y-4 w-full max-w-sm mx-auto p-6 border rounded-lg shadow-md bg-white">
<h2 className="text-2xl font-semibold text-center">Login</h2>
{/* Display general success or error messages */}
{state?.message && !state.errors?.credentials && !state.errors?.email && !state.errors?.password && (
<p className={`text-sm p-2 rounded ${state.errors ? 'text-red-600 bg-red-100' : 'text-green-600 bg-green-100'}`}>
{state.message}
</p>
)}
{/* Display specific credentials error */}
{state?.errors?.credentials && (
<p className="text-sm text-red-600 bg-red-100 p-2 rounded">{state.errors.credentials.join(', ')}</p>
)}
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="you@example.com"
required
aria-describedby="email-error"
/>
{state?.errors?.email && (
<p id="email-error" className="text-sm text-red-500">
{state.errors.email.join(', ')}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
required
minLength={6}
aria-describedby="password-error"
/>
{state?.errors?.password && (
<p id="password-error" className="text-sm text-red-500">
{state.errors.password.join(', ')}
</p>
)}
</div>
<Button type="submit" className="w-full" aria-disabled={isPending}>
{isPending ? 'Logging in…' : 'Log In'}
</Button>
<p className="text-center text-sm text-gray-600">
Don't have an account?{' '}
<Link href="/signup" className="text-blue-600 hover:underline">
Sign Up
</Link>
</p>
</form>
);
}
// src/components/auth/SignUpForm.tsx
'use client';
import { useActionState, useEffect } from 'react';
import { registerUser, SignUpState } from '@/lib/actions/auth';
import { Button } from '@/components/ui/button'; // Adjust path
import { Input } from '@/components/ui/input'; // Adjust path
import { Label } from '@/components/ui/label'; // Adjust path
import { useRouter } from 'next/navigation';
import Link from 'next/link'; // Use Next.js Link
export default function SignUpForm() {
const initialState: SignUpState | undefined = undefined;
const [state, formAction, isPending] = useActionState(registerUser, initialState);
const router = useRouter();
// Redirect on successful registration
useEffect(() => {
if (state?.success) {
// Consider showing a success toast/notification here instead of alert
console.log("Registration successful:", state.message);
// Redirect to login page after successful registration
router.push('/login');
}
}, [state?.success, state?.message, router]);
return (
<form action={formAction} className="space-y-4 w-full max-w-sm mx-auto p-6 border rounded-lg shadow-md bg-white">
<h2 className="text-2xl font-semibold text-center">Sign Up</h2>
{/* Display general error messages (not success message, as we redirect) */}
{state?.message && !state.success && (
<p className="text-sm text-red-600 bg-red-100 p-2 rounded">{state.message}</p>
)}
{state?.errors?.server && (
<p className="text-sm text-red-600 bg-red-100 p-2 rounded">{state.errors.server.join(', ')}</p>
)}
<div className="space-y-1">
<Label htmlFor="name">Name</Label>
{/* ... (rest of SignUpForm component - input fields for name, email, phone, password) ... */}
{/* Make sure inputs have correct id, name, type, required attributes, and error handling similar to LoginForm */}
{/* Example for Name input: */}
<Input
id="name"
name="name"
type="text"
placeholder="Your Name"
required
aria-describedby="name-error"
/>
{state?.errors?.name && (
<p id="name-error" className="text-sm text-red-500">
{state.errors.name.join(', ')}
</p>
)}
</div>
{/* Add similar divs for Email, Phone Number, and Password */}
<div className="space-y-1">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="you@example.com"
required
aria-describedby="signup-email-error"
/>
{state?.errors?.email && (
<p id="signup-email-error" className="text-sm text-red-500">
{state.errors.email.join(', ')}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="phoneNumber">Phone Number (e.g., +15551234567)</Label>
<Input
id="phoneNumber"
name="phoneNumber"
type="tel" // Use type="tel" for phone numbers
placeholder="+15551234567"
required
aria-describedby="phoneNumber-error"
/>
{state?.errors?.phoneNumber && (
<p id="phoneNumber-error" className="text-sm text-red-500">
{state.errors.phoneNumber.join(', ')}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="signup-password">Password</Label>
<Input
id="signup-password"
name="password"
type="password"
required
minLength={6}
aria-describedby="signup-password-error"
/>
{state?.errors?.password && (
<p id="signup-password-error" className="text-sm text-red-500">
{state.errors.password.join(', ')}
</p>
)}
</div>
<Button type="submit" className="w-full" aria-disabled={isPending}>
{isPending ? 'Signing Up…' : 'Sign Up'}
</Button>
<p className="text-center text-sm text-gray-600">
Already have an account?{' '}
<Link href="/login" className="text-blue-600 hover:underline">
Log In
</Link>
</p>
</form>
);
}Frequently Asked Questions
How to set up inbound SMS with Sinch and Next.js?
This guide provides a step-by-step process to integrate Sinch's inbound SMS capabilities into your Next.js application. It utilizes NextAuth.js for user authentication and Prisma for database interactions, allowing you to securely receive and process SMS messages sent to a dedicated Sinch number linked to user accounts.
What is the role of NextAuth.js in the Sinch SMS integration?
NextAuth.js simplifies the implementation of user authentication in your Next.js application. This is crucial for associating incoming SMS messages with specific authenticated user accounts based on their registered phone numbers.
Why is E.164 format important for phone numbers?
E.164 format (+15551234567) ensures consistent handling of phone numbers, which is essential for reliably linking inbound SMS messages from Sinch to the corresponding user account. It is a globally recognized standard that enables phone number validation, verification and compatibility across different systems, preventing errors or mismatches caused by regional variations.
When should I use the Sinch SDK with this integration?
The Sinch SDK is optional for this specific guide, which focuses on receiving inbound SMS messages. However, the SDK is required if you extend the functionality to send replies or other outbound communications.
Can I use other databases besides PostgreSQL with this setup?
While this guide uses PostgreSQL, you can adapt it to use other databases by adjusting the `DATABASE_URL` and configuring the appropriate Prisma provider in the `schema.prisma` file.
How to secure the Sinch webhook endpoint?
Webhook security is paramount. Generate a strong secret with `openssl rand -base64 32` and configure it in both your `.env` file (`SINCH_WEBHOOK_SECRET`) and the Sinch dashboard for the specific webhook endpoint. This secret will be used to verify the signature of incoming webhook requests, ensuring they originate from Sinch and not malicious actors.
What is the purpose of the system architecture diagram?
The diagram visually illustrates the flow of requests and data between the user, your Next.js application, the Sinch platform, and the database. It highlights the interactions with NextAuth.js for login requests and Prisma for database access, and showcases how the webhooks are processed via an API route.
How to test Sinch webhooks locally?
Use tools like ngrok or the Vercel CLI to expose your local development server to the internet. This will allow Sinch to send webhooks to your local endpoint during development and testing. Make sure to configure the generated URL from ngrok or Vercel as your webhook URL in Sinch.
What are the prerequisites for this tutorial?
You'll need Node.js, access to a PostgreSQL database, a Sinch account with API credentials and a dedicated phone number, and basic understanding of React, TypeScript, and REST APIs.
How does the application identify the user from an incoming SMS message?
The application assumes that SMS messages are sent in E.164 format. It looks up the user in the database whose `phoneNumber` field matches the incoming `fromNumber` received in the Sinch webhook payload. Accurate and consistent use of E.164 is critical for correct user identification.
What Next.js version is compatible with this guide?
The guide is designed for Next.js version 14 or later, using the App Router.
What is Prisma, and what's its role?
Prisma is a modern Object-Relational Mapper (ORM) that simplifies database interactions. It provides a type-safe way to access and manipulate data in your PostgreSQL database, including creating, updating, and querying users and message data.
Why use TypeScript in a Next.js project?
TypeScript adds static typing to your project, which improves code maintainability, developer experience with enhanced autocompletion and error detection, and helps catch errors early in development. It's recommended to leverage TypeScript in any large-scale Next.js application.
How are user passwords secured in this application?
User passwords are securely hashed using bcryptjs before being stored in the database. This ensures that even if the database is compromised, the raw passwords remain protected.
How to deploy the finished application?
This guide doesn't explicitly cover deployment steps, however common platforms for deploying a Next.js app include Vercel, Netlify, AWS Amplify, or Google Cloud Run. In your chosen production environment, ensure your environment variables are correctly set, and database connections configured.