MessageBird SMS Scheduling and Reminders with Next.js and Supabase
Build a production-ready SMS scheduling and reminder system with MessageBird, Next.js, and Supabase. Learn to schedule SMS reminders, manage automated messages, implement Supabase Cron, and handle time zones, error handling, and delivery tracking.
Build a production-ready SMS scheduling and reminder system with MessageBird, Next.js, and Supabase. This comprehensive guide shows you how to schedule SMS reminders, manage automated messages through API routes, implement Supabase Cron for reliable task scheduling, and handle time zones, error handling, and delivery tracking in a scalable Node.js application.
Estimated completion time: 2–3 hours
Skill level: Intermediate (requires familiarity with Node.js, React, and REST APIs)
Minimum System Requirements: 4GB RAM, modern multi-core processor, 1GB free disk space
By the end of this MessageBird SMS scheduling tutorial, you'll have built a production-ready Next.js application that:
Accepts API requests to schedule SMS messages via MessageBird API
Stores scheduled messages persistently in Supabase PostgreSQL database
Reliably sends messages at scheduled times using Supabase Cron or node-cron
Provides RESTful API endpoints to manage (view, cancel) scheduled messages
Implements comprehensive error handling, retry logic, and timezone management
Handles delivery receipts and message status tracking
Target Audience: Node.js developers building SMS reminder systems, appointment notification services, or marketing automation tools who need to implement reliable scheduled messaging with MessageBird and Supabase.
Technologies Used:
Node.js: JavaScript runtime environment.
Next.js: React framework for building full-stack web applications with API routes.
MessageBird SMS API: Cloud communications platform for sending SMS messages (pricing varies by destination; test credits available).
Supabase: Open-source Firebase alternative with PostgreSQL database, authentication, and real-time subscriptions.
node-cron: Alternative task scheduler library for Node.js based on cron syntax (for development or when not using Supabase Cron).
dotenv: Module to load environment variables from a .env.local file.
Cost Considerations:
MessageBird charges per SMS sent; rates vary by destination country (see pricing)
MessageBird offers test credits for new accounts
MessageBird enforces rate limits: 500 req/s for POST (SMS sending), 50 req/s for GET operations
Supabase offers a free tier; paid plans start at $25/month for production workloads
Monitor usage to avoid unexpected charges
System Architecture:
graph LR
A[User / Client<br/>(Browser, API)] --> B(Next.js API Routes<br/>/api/schedule);
B --> C{Scheduling Service<br/>(Supabase Cron or node-cron)};
C --> D[MessageBird<br/>SMS API];
D --> E[User Phone<br/>(SMS Delivery)];
subgraph Next.js Application
B --> B1(Input Validation);
B --> B2(Error Handling);
B --> B3(Logging);
end
subgraph Supabase Backend
C --> C1[(PostgreSQL Database<br/>Scheduled Messages)];
end
B --> C1; // Next.js API interacts with Supabase DB
C --> C1; // Cron job queries pending messages
D --> F[Webhook Handler<br/>(Delivery Receipts)];
F --> C1; // Update message status
Prerequisites:
Node.js and npm: Install Node.js 18+ on your system. Download from nodejs.org. Verify installation: node -v and npm -v.
MessageBird Virtual Mobile Number (VMN): Purchase a number with SMS capabilities for your region. Navigate to Numbers → Buy numbers in the Dashboard. Note: Some countries have registration requirements.
Supabase Account and Project: Sign up at supabase.com and create a new project. Get your Project URL and anon/public API key from Project Settings → API.
Basic Terminal/Command Line Knowledge: Know how to navigate directories and run commands.
(Optional) curl or Postman: For testing the API endpoints.
1. Project Setup and Installation
Initialize your Next.js SMS scheduling project and install the MessageBird SDK, Supabase client, and scheduling dependencies.
Common Setup Issues & Solutions:
Port already in use: Change PORT in .env.local or kill the process using the port
Never commit .env.local to version control (included in .gitignore by default)
Use NEXT_PUBLIC_ prefix only for client-accessible variables
Service role key grants admin access—use only in server-side code (API routes, not client)
Rotate API keys every 90 days or when team members leave
Use environment-specific files: .env.development, .env.production
In production, use secure secrets management:
Vercel: Environment Variables in project settings
AWS: Secrets Manager or Parameter Store
Azure: Key Vault
GCP: Secret Manager
Validate required environment variables at startup (see Section 5)
Document all environment variables in .env.example:
.env
# .env.example (commit this file)
MESSAGEBIRD_API_KEY=
MESSAGEBIRD_ORIGINATOR=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
MESSAGEBIRD_API_KEY: Find in MessageBird Dashboard → Developers → API access keys (guide).
MESSAGEBIRD_ORIGINATOR: The sender ID shown to recipients (alphanumeric, max 11 chars, or a phone number). Note: Some countries require registered sender IDs—see country restrictions.
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY: Find in Supabase Project Settings → API.
SUPABASE_SERVICE_ROLE_KEY: Admin-level key, found in same location. Keep this secret!
CRON_SCHEDULE: How often to check for due messages. */30 * * * * * = every 30 seconds (development). Use * * * * * (every minute) for production. See crontab.guru.
APP_TIMEZONE: Use UTC for consistency. Supabase stores timestamps in UTC by default.
Project Structure:
Next.js uses file-based routing. Create this structure:
Create a table named scheduled_messages with these columns:
Column Name
Type
Default
Nullable
Notes
id
uuid
gen_random_uuid()
No
Primary key
recipient
text
No
Phone number (E.164 format preferred)
message
text
No
SMS content
send_at
timestamptz
No
Scheduled send time (UTC)
status
text
'PENDING'
No
PENDING, SENT, FAILED, CANCELED
messagebird_id
text
Yes
MessageBird message ID after sending
created_at
timestamptz
now()
No
Record creation time
updated_at
timestamptz
now()
No
Last update time
error_message
text
Yes
Error details on failure
Database Indexing for Performance:
Add indexes to optimize queries for pending messages:
SQL
-- Run in Supabase SQL EditorCREATE INDEX idx_scheduled_messages_status_send_at
ON scheduled_messages (status, send_at)
WHERE status ='PENDING';
CREATE INDEX idx_scheduled_messages_created_at
ON scheduled_messages (created_at DESC);
Why these indexes?
idx_scheduled_messages_status_send_at: Critical for the cron job query that finds pending messages due for sending
Partial index (WHERE status = 'PENDING') reduces index size and improves performance
idx_scheduled_messages_created_at: Speeds up listing recent messages
Field Constraints & Validation:
Add constraints at database level for data integrity:
SQL
-- Message length constraint (SMS is typically 160 chars for GSM, 70 for Unicode)ALTER TABLE scheduled_messages
ADD CONSTRAINT message_length_check
CHECK (char_length(message) <=1600);
-- Phone number format validation (basic check for E.164-like format)ALTER TABLE scheduled_messages
ADD CONSTRAINT recipient_format_check
CHECK (recipient ~'^\+?[1-9]\d{1,14}$');
-- Status enum constraintALTER TABLE scheduled_messages
ADD CONSTRAINT status_check
CHECK (status IN ('PENDING', 'SENT', 'FAILED', 'CANCELED'));
-- Ensure send_at is in the future for new recordsALTER TABLE scheduled_messages
ADD CONSTRAINT send_at_future_check
CHECK (send_at > now());
Enable Row Level Security (RLS):
For production, enable RLS to control access:
SQL
ALTER TABLE scheduled_messages ENABLE ROW LEVEL SECURITY;
-- Policy: Allow service role to do anything (for API routes)CREATE POLICY "Service role can manage all scheduled messages"
ON scheduled_messages
FORALLTO service_role
USING (true)
WITHCHECK (true);
-- Policy: Authenticated users can view their own messages (if you add user_id column)-- CREATE POLICY "Users can view own messages"-- ON scheduled_messages-- FOR SELECT-- TO authenticated-- USING (auth.uid() = user_id);
Set Up Updated_at Trigger:
Automatically update updated_at on row changes:
SQL
CREATEOR REPLACE FUNCTION update_updated_at_column()
RETURNSTRIGGERAS $$
BEGIN
NEW.updated_at = now();
RETURNNEW;
END;
$$ LANGUAGE plpgsql;
CREATETRIGGER update_scheduled_messages_updated_at
BEFORE UPDATEON scheduled_messages
FOREACHROWEXECUTEFUNCTION update_updated_at_column();
In Next.js, API routes are stateless and don't require explicit connection cleanup. However, for long-running processes (like cron jobs), implement graceful shutdown:
5. Implement SMS Scheduling Logic with Supabase Cron
Implement production-grade SMS scheduling using Supabase's native pg_cron extension or node-cron for automated message delivery.
Distributed Scheduling Concerns:
Horizontal scaling: Running multiple Next.js instances can cause duplicate executions
Solutions:
Supabase Cron (recommended): Single scheduler within Postgres, no race conditions
Distributed locks: Use pg_advisory_lock for node-cron in multi-instance setups
Dedicated scheduler service: Single cron instance, multiple API instances
Using Supabase Cron (Recommended for Production):
Enable pg_cron Extension:
In Supabase Dashboard, go to Database → Extensions, search for pg_cron, and enable it.
Create Cron Job to Process Pending Messages:
In Supabase SQL Editor, run:
SQL
-- Create function to process pending messagesCREATEOR REPLACE FUNCTION process_pending_messages()
RETURNS void AS $$
DECLARE
msg RECORD;
response TEXT;
BEGIN-- Find messages due for sendingFOR msg INSELECT id, recipient, message, send_at
FROM scheduled_messages
WHERE status ='PENDING'AND send_at <= NOW()
FORUPDATESKIP LOCKED -- Prevent concurrent processing
LOOP
BEGIN-- Call Next.js API route to send SMSSELECT content INTO response
FROM http((
'POST',
'https://your-domain.com/api/send-sms-internal', -- Replace with your domainARRAY[http_header('Content-Type', 'application/json')],
'application/json',
json_build_object(
'id', msg.id,
'recipient', msg.recipient,
'message', msg.message
)::text
));
-- Log success
RAISE NOTICE 'Processed message %', msg.id;
EXCEPTION WHEN OTHERS THEN-- Update message status to FAILED on errorUPDATE scheduled_messages
SET status ='FAILED', error_message = SQLERRM, updated_at = NOW()
WHERE id = msg.id;
RAISE WARNING 'Failed to process message %: %', msg.id, SQLERRM;
END;
END LOOP;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Schedule the function to run every minuteSELECT cron.schedule(
'process-pending-sms', -- Job name'* * * * *', -- Every minute
$$SELECT process_pending_messages();$$
);
Note: Install the pg_net or http extension to make HTTP requests from Postgres. Alternatively, process messages directly in Postgres using a MessageBird SDK wrapper (advanced).
Alternative: Using node-cron in Next.js (Development/Simple Deployments):
If not using Supabase Cron, implement scheduling in Node.js:
Create Scheduler Service (src/lib/scheduler.ts):
TypeScript
// src/lib/scheduler.tsimport cron from'node-cron';
import { supabaseAdmin } from'./supabase';
import { sendSMS } from'./messagebird';
letcronJob: cron.ScheduledTask | null = null;
exportfunctionstartScheduler() {
if (cronJob) {
console.log('Scheduler already running');
return;
}
const schedule = process.env.CRON_SCHEDULE || '* * * * *'; // Every minuteif (!cron.validate(schedule)) {
console.error(`Invalid CRON_SCHEDULE: ${schedule}, defaulting to every minute`);
}
cronJob = cron.schedule(
cron.validate(schedule) ? schedule : '* * * * *',
async () => {
console.log(`[${newDate().toISOString()}] Checking for due messages...`);
awaitcheckAndSendDueMessages();
},
{
scheduled: true,
timezone: process.env.APP_TIMEZONE || 'UTC',
}
);
console.log(`Scheduler started with pattern: ${schedule}`);
}
exportfunctionstopScheduler() {
if (cronJob) {
cronJob.stop();
cronJob = null;
console.log('Scheduler stopped');
}
}
asyncfunctioncheckAndSendDueMessages() {
try {
const now = newDate();
// Query pending messages due for sendingconst { data: dueMessages, error } = await supabaseAdmin
.from('scheduled_messages')
.select('*')
.eq('status', 'PENDING')
.lte('send_at', now.toISOString())
.order('send_at', { ascending: true });
if (error) throw error;
if (!dueMessages || dueMessages.length === 0) {
return; // No messages to send
}
console.log(`Found ${dueMessages.length} due messages. Processing...`);
for (const msg of dueMessages) {
try {
console.log(`Sending message ID: ${msg.id} to ${msg.recipient}`);
const result = awaitsendSMS(msg.recipient, msg.message);
// Update status to SENTawait supabaseAdmin
.from('scheduled_messages')
.update({
status: 'SENT',
messagebird_id: result.id,
updated_at: newDate().toISOString(),
})
.eq('id', msg.id);
console.log(`Message ${msg.id} sent successfully. MessageBird ID: ${result.id}`);
} catch (err: any) {
console.error(`Failed to send message ${msg.id}:`, err);
// Update status to FAILEDawait supabaseAdmin
.from('scheduled_messages')
.update({
status: 'FAILED',
error_message: err.message || 'Unknown error',
updated_at: newDate().toISOString(),
})
.eq('id', msg.id);
}
}
} catch (error) {
console.error('Error during message check/send process:', error);
}
}
Start Scheduler in API Route (src/app/api/cron/start/route.ts):
For production deployment, review MessageBird's country-specific regulations and ensure compliance with SMS messaging laws (TCPA in USA, GDPR in EU, etc.).
Frequently asked questions
Use Node.js with Express, the Vonage Messages API, and node-cron to build an SMS scheduler. Create API endpoints to handle scheduling requests, store messages in a database (like SQLite), and set up node-cron to trigger sending at specified times via Vonage.
The Vonage Messages API is the core component for sending SMS messages in the Node.js SMS scheduler application. It's integrated with the scheduler via the Vonage Node.js SDK and uses API credentials or application ID/private key authentication to send the scheduled messages.
Node-cron is a task scheduler based on cron syntax, enabling timed execution of code. In the SMS scheduler, it periodically checks the database for messages due to be sent and triggers the sending process through the Vonage API.
Implement an SMS scheduler when you need to send messages at predefined future times, like appointment reminders, promotional campaigns, or time-sensitive alerts. This Node.js tutorial provides a practical guide.
Yes, the example uses SQLite for simplicity, but you can easily switch to other databases like PostgreSQL or MySQL. Update the DATABASE_URL in the .env file and configure Prisma accordingly.
Obtain API Key and Secret from the Vonage Dashboard, generate an application with private key authentication (preferred), and link a Vonage virtual number. Store these securely in a .env file, ensuring it's in .gitignore.
Prisma is an ORM (Object-Relational Mapper) for Node.js and TypeScript. It simplifies database interactions by letting you define your data model in a schema file (schema.prisma) and then use the Prisma Client to query and update data in your database.
The APP_TIMEZONE variable sets the scheduler's timezone, and using UTC is strongly recommended for consistency. Store and schedule times in UTC to prevent issues with daylight saving time or regional time changes.
You can test the API using tools like curl or Postman. Send POST requests to /api/schedule with message details, then GET requests to /api/schedule/:id to check status and DELETE to cancel.
You'll need Node.js and npm (or yarn), a Vonage API account with a number, basic command line knowledge, and optionally curl or Postman for testing. Familiarity with Express.js and cron syntax is helpful.
The private.key file contains your Vonage application's private key, used for authenticating with the Vonage Messages API. Keep this secure; never commit it to version control. It’s generated when setting up a Vonage application.
The code includes error handling for API requests, database interactions, and the Vonage API itself. The service functions return specific errors, and controllers pass unexpected errors to a centralized error handler in server.js that returns appropriate JSON responses.
Scheduled messages are stored in a database defined using Prisma. The default configuration uses SQLite, and the message details are stored in a table called ScheduledMessage based on the Prisma schema definition.
Build a production-ready SMS scheduling and reminder system with MessageBird, Next.js, and Supabase. This comprehensive guide shows you how to schedule SMS reminders, manage automated messages through API routes, implement Supabase Cron for reliable task scheduling, and handle time zones, error handling, and delivery tracking in a scalable Node.js application.
Estimated completion time: 2–3 hours
Skill level: Intermediate (requires familiarity with Node.js, React, and REST APIs)
Version Compatibility:
messagebird@^4.0.1(npm package, API docs)@supabase/supabase-js@^2.x(Supabase docs)^3.0.xMinimum System Requirements: 4GB RAM, modern multi-core processor, 1GB free disk space
By the end of this MessageBird SMS scheduling tutorial, you'll have built a production-ready Next.js application that:
Target Audience: Node.js developers building SMS reminder systems, appointment notification services, or marketing automation tools who need to implement reliable scheduled messaging with MessageBird and Supabase.
Technologies Used:
node-cron: Alternative task scheduler library for Node.js based on cron syntax (for development or when not using Supabase Cron).dotenv: Module to load environment variables from a.env.localfile.Cost Considerations:
System Architecture:
Prerequisites:
node -vandnpm -v.curlor Postman: For testing the API endpoints.1. Project Setup and Installation
Initialize your Next.js SMS scheduling project and install the MessageBird SDK, Supabase client, and scheduling dependencies.
Common Setup Issues & Solutions:
PORTin.env.localor kill the process using the portnpm cache clean --force) and retryCreate Next.js Project:
Open your terminal and create a new Next.js project with TypeScript support.
npx create-next-app@latest sms-scheduler cd sms-schedulerWhen prompted, select:
src/directory: Yes (recommended)Install Dependencies:
Install MessageBird SDK, Supabase client,
node-cronfor scheduling, anddotenv.Package Versions & Security:
package.jsonto avoid breaking changes:"messagebird": "^4.0.1"npm auditregularly to check for vulnerabilitiesnpm audit fixto automatically patch security issuesnpm ciin production for reproducible buildsmessagebird: Official MessageBird Node.js SDK (npm, GitHub).@supabase/supabase-js: Supabase JavaScript client.node-cron: Task scheduler (for development; production should use Supabase Cron).Configure Environment Variables:
Create a
.env.localfile in the project root (Next.js convention for local environment variables).Environment Variable Security & Best Practices:
.env.localto version control (included in.gitignoreby default)NEXT_PUBLIC_prefix only for client-accessible variables.env.development,.env.production.env.example:MESSAGEBIRD_API_KEY: Find in MessageBird Dashboard → Developers → API access keys (guide).MESSAGEBIRD_ORIGINATOR: The sender ID shown to recipients (alphanumeric, max 11 chars, or a phone number). Note: Some countries require registered sender IDs—see country restrictions.NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY: Find in Supabase Project Settings → API.SUPABASE_SERVICE_ROLE_KEY: Admin-level key, found in same location. Keep this secret!CRON_SCHEDULE: How often to check for due messages.*/30 * * * * *= every 30 seconds (development). Use* * * * *(every minute) for production. See crontab.guru.APP_TIMEZONE: UseUTCfor consistency. Supabase stores timestamps in UTC by default.Project Structure:
Next.js uses file-based routing. Create this structure:
Why this structure?
src/app/): Next.js 13+ recommended approach with React Server Componentssrc/app/api/): Server-side endpoints for scheduling operationslib/: Reusable utility modules (Supabase, MessageBird clients, scheduler)types/: Centralized TypeScript type definitionslib/2. MessageBird API Configuration
Configure your MessageBird account, obtain API credentials, and set up SMS sender IDs for scheduled message delivery.
MessageBird Overview:
Get MessageBird API Key:
.env.localfile asMESSAGEBIRD_API_KEY.Buy a Virtual Mobile Number (VMN):
MESSAGEBIRD_ORIGINATOR.Webhook Setup for Delivery Receipts (Optional but Recommended):
MessageBird can send delivery status updates (DLRs) via webhooks when messages are delivered or fail.
reportUrlparameter).https://yourdomain.com/api/webhooks/messagebird.src/app/api/webhooks/messagebird/route.tsto update message status in Supabase.MessageBird Rate Limits & Best Practices:
referencefield to correlate messages with your database recordsreportUrlfor per-message delivery receipt webhooks3. Supabase Database Setup for SMS Scheduling
Create the PostgreSQL database schema in Supabase to store scheduled messages with proper indexing for performance.
Supabase Overview:
Create Supabase Table:
scheduled_messageswith these columns:iduuidgen_random_uuid()recipienttextmessagetextsend_attimestamptzstatustext'PENDING'messagebird_idtextcreated_attimestamptznow()updated_attimestamptznow()error_messagetextDatabase Indexing for Performance:
Add indexes to optimize queries for pending messages:
-- Run in Supabase SQL Editor CREATE INDEX idx_scheduled_messages_status_send_at ON scheduled_messages (status, send_at) WHERE status = 'PENDING'; CREATE INDEX idx_scheduled_messages_created_at ON scheduled_messages (created_at DESC);Why these indexes?
idx_scheduled_messages_status_send_at: Critical for the cron job query that finds pending messages due for sendingWHERE status = 'PENDING') reduces index size and improves performanceidx_scheduled_messages_created_at: Speeds up listing recent messagesField Constraints & Validation:
Add constraints at database level for data integrity:
-- Message length constraint (SMS is typically 160 chars for GSM, 70 for Unicode) ALTER TABLE scheduled_messages ADD CONSTRAINT message_length_check CHECK (char_length(message) <= 1600); -- Phone number format validation (basic check for E.164-like format) ALTER TABLE scheduled_messages ADD CONSTRAINT recipient_format_check CHECK (recipient ~ '^\+?[1-9]\d{1,14}$'); -- Status enum constraint ALTER TABLE scheduled_messages ADD CONSTRAINT status_check CHECK (status IN ('PENDING', 'SENT', 'FAILED', 'CANCELED')); -- Ensure send_at is in the future for new records ALTER TABLE scheduled_messages ADD CONSTRAINT send_at_future_check CHECK (send_at > now());Enable Row Level Security (RLS):
For production, enable RLS to control access:
ALTER TABLE scheduled_messages ENABLE ROW LEVEL SECURITY; -- Policy: Allow service role to do anything (for API routes) CREATE POLICY "Service role can manage all scheduled messages" ON scheduled_messages FOR ALL TO service_role USING (true) WITH CHECK (true); -- Policy: Authenticated users can view their own messages (if you add user_id column) -- CREATE POLICY "Users can view own messages" -- ON scheduled_messages -- FOR SELECT -- TO authenticated -- USING (auth.uid() = user_id);Set Up Updated_at Trigger:
Automatically update
updated_aton row changes:CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER update_scheduled_messages_updated_at BEFORE UPDATE ON scheduled_messages FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();Migration Workflow & Rollback:
Use Supabase migrations for version control:
# Install Supabase CLI npm install -g supabase # Initialize local Supabase supabase init # Create migration file supabase migration new create_scheduled_messages_table # Apply migrations (local) supabase db reset # Apply migrations (remote) supabase db push # Rollback (if needed) supabase migration repair --status revertedProduction migration strategy:
4. Initialize Supabase and MessageBird Clients
Create reusable client initialization modules.
Create Supabase Client (
src/lib/supabase.ts):// src/lib/supabase.ts import { createClient } from '@supabase/supabase-js'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!; if (!supabaseUrl || !supabaseServiceKey) { throw new Error('Missing Supabase environment variables'); } // Use service role key for server-side operations (bypasses RLS) export const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, { auth: { autoRefreshToken: false, persistSession: false, }, }); // For client-side operations (respects RLS) const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; export const supabaseClient = createClient(supabaseUrl, supabaseAnonKey); // Database types export interface ScheduledMessage { id: string; recipient: string; message: string; send_at: string; // ISO 8601 timestamp status: 'PENDING' | 'SENT' | 'FAILED' | 'CANCELED'; messagebird_id: string | null; created_at: string; updated_at: string; error_message: string | null; }Supabase Connection Pooling:
Create MessageBird Client (
src/lib/messagebird.ts):// src/lib/messagebird.ts import messagebird from 'messagebird'; const apiKey = process.env.MESSAGEBIRD_API_KEY; if (!apiKey) { throw new Error('MESSAGEBIRD_API_KEY is not set'); } export const messagebirdClient = messagebird(apiKey); export const ORIGINATOR = process.env.MESSAGEBIRD_ORIGINATOR || 'MessageBird'; // Helper function to send SMS export async function sendSMS( recipient: string, message: string ): Promise<{ id: string; status: string }> { return new Promise((resolve, reject) => { messagebirdClient.messages.create( { originator: ORIGINATOR, recipients: [recipient], body: message, }, (err, response) => { if (err) { reject(err); } else { resolve({ id: response.id, status: response.recipients.items[0]?.status || 'unknown', }); } } ); }); }Graceful Shutdown Handling:
In Next.js, API routes are stateless and don't require explicit connection cleanup. However, for long-running processes (like cron jobs), implement graceful shutdown:
// src/lib/shutdown.ts export function setupGracefulShutdown() { const signals = ['SIGTERM', 'SIGINT', 'SIGUSR2'] as const; signals.forEach((signal) => { process.on(signal, async () => { console.log(`Received ${signal}, shutting down gracefully...`); // Stop accepting new cron job executions // Finish pending database operations // Close connections if needed process.exit(0); }); }); }5. Implement SMS Scheduling Logic with Supabase Cron
Implement production-grade SMS scheduling using Supabase's native pg_cron extension or node-cron for automated message delivery.
Distributed Scheduling Concerns:
pg_advisory_lockfor node-cron in multi-instance setupsUsing Supabase Cron (Recommended for Production):
Enable pg_cron Extension:
In Supabase Dashboard, go to Database → Extensions, search for
pg_cron, and enable it.Create Cron Job to Process Pending Messages:
In Supabase SQL Editor, run:
-- Create function to process pending messages CREATE OR REPLACE FUNCTION process_pending_messages() RETURNS void AS $$ DECLARE msg RECORD; response TEXT; BEGIN -- Find messages due for sending FOR msg IN SELECT id, recipient, message, send_at FROM scheduled_messages WHERE status = 'PENDING' AND send_at <= NOW() FOR UPDATE SKIP LOCKED -- Prevent concurrent processing LOOP BEGIN -- Call Next.js API route to send SMS SELECT content INTO response FROM http(( 'POST', 'https://your-domain.com/api/send-sms-internal', -- Replace with your domain ARRAY[http_header('Content-Type', 'application/json')], 'application/json', json_build_object( 'id', msg.id, 'recipient', msg.recipient, 'message', msg.message )::text )); -- Log success RAISE NOTICE 'Processed message %', msg.id; EXCEPTION WHEN OTHERS THEN -- Update message status to FAILED on error UPDATE scheduled_messages SET status = 'FAILED', error_message = SQLERRM, updated_at = NOW() WHERE id = msg.id; RAISE WARNING 'Failed to process message %: %', msg.id, SQLERRM; END; END LOOP; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- Schedule the function to run every minute SELECT cron.schedule( 'process-pending-sms', -- Job name '* * * * *', -- Every minute $$SELECT process_pending_messages();$$ );Note: Install the
pg_netorhttpextension to make HTTP requests from Postgres. Alternatively, process messages directly in Postgres using a MessageBird SDK wrapper (advanced).Alternative: Using node-cron in Next.js (Development/Simple Deployments):
If not using Supabase Cron, implement scheduling in Node.js:
Create Scheduler Service (
src/lib/scheduler.ts):// src/lib/scheduler.ts import cron from 'node-cron'; import { supabaseAdmin } from './supabase'; import { sendSMS } from './messagebird'; let cronJob: cron.ScheduledTask | null = null; export function startScheduler() { if (cronJob) { console.log('Scheduler already running'); return; } const schedule = process.env.CRON_SCHEDULE || '* * * * *'; // Every minute if (!cron.validate(schedule)) { console.error(`Invalid CRON_SCHEDULE: ${schedule}, defaulting to every minute`); } cronJob = cron.schedule( cron.validate(schedule) ? schedule : '* * * * *', async () => { console.log(`[${new Date().toISOString()}] Checking for due messages...`); await checkAndSendDueMessages(); }, { scheduled: true, timezone: process.env.APP_TIMEZONE || 'UTC', } ); console.log(`Scheduler started with pattern: ${schedule}`); } export function stopScheduler() { if (cronJob) { cronJob.stop(); cronJob = null; console.log('Scheduler stopped'); } } async function checkAndSendDueMessages() { try { const now = new Date(); // Query pending messages due for sending const { data: dueMessages, error } = await supabaseAdmin .from('scheduled_messages') .select('*') .eq('status', 'PENDING') .lte('send_at', now.toISOString()) .order('send_at', { ascending: true }); if (error) throw error; if (!dueMessages || dueMessages.length === 0) { return; // No messages to send } console.log(`Found ${dueMessages.length} due messages. Processing...`); for (const msg of dueMessages) { try { console.log(`Sending message ID: ${msg.id} to ${msg.recipient}`); const result = await sendSMS(msg.recipient, msg.message); // Update status to SENT await supabaseAdmin .from('scheduled_messages') .update({ status: 'SENT', messagebird_id: result.id, updated_at: new Date().toISOString(), }) .eq('id', msg.id); console.log(`Message ${msg.id} sent successfully. MessageBird ID: ${result.id}`); } catch (err: any) { console.error(`Failed to send message ${msg.id}:`, err); // Update status to FAILED await supabaseAdmin .from('scheduled_messages') .update({ status: 'FAILED', error_message: err.message || 'Unknown error', updated_at: new Date().toISOString(), }) .eq('id', msg.id); } } } catch (error) { console.error('Error during message check/send process:', error); } }Start Scheduler in API Route (
src/app/api/cron/start/route.ts):// src/app/api/cron/start/route.ts import { NextResponse } from 'next/server'; import { startScheduler } from '@/lib/scheduler'; export async function POST() { try { startScheduler(); return NextResponse.json({ message: 'Scheduler started' }); } catch (error: any) { return NextResponse.json( { error: error.message }, { status: 500 } ); } }Important: Call this endpoint once when you deploy your app, or invoke
startScheduler()in a global initialization script.Retry Logic for Failed Messages:
Implement exponential backoff retry for transient failures:
// Add to scheduler.ts const MAX_RETRIES = 3; const RETRY_DELAYS = [60000, 300000, 900000]; // 1min, 5min, 15min async function sendWithRetry(msg: ScheduledMessage, attempt = 0): Promise<void> { try { const result = await sendSMS(msg.recipient, msg.message); await supabaseAdmin .from('scheduled_messages') .update({ status: 'SENT', messagebird_id: result.id, updated_at: new Date().toISOString(), }) .eq('id', msg.id); } catch (err: any) { if (attempt < MAX_RETRIES && isRetriableError(err)) { const delay = RETRY_DELAYS[attempt]; console.log(`Retrying message ${msg.id} in ${delay}ms (attempt ${attempt + 1})`); setTimeout(() => sendWithRetry(msg, attempt + 1), delay); } else { // Max retries exceeded or non-retriable error await supabaseAdmin .from('scheduled_messages') .update({ status: 'FAILED', error_message: `${err.message} (after ${attempt + 1} attempts)`, updated_at: new Date().toISOString(), }) .eq('id', msg.id); } } } function isRetriableError(err: any): boolean { // Retry on rate limits (429) or server errors (5xx) return err.statusCode === 429 || (err.statusCode >= 500 && err.statusCode < 600); }Rate Limiting to Prevent API Throttling:
Implement rate limiting to respect MessageBird's 500 req/s POST limit:
// src/lib/rate-limiter.ts class RateLimiter { private tokens: number; private lastRefill: number; private readonly maxTokens: number; private readonly refillRate: number; // tokens per second constructor(maxTokens: number, refillRate: number) { this.maxTokens = maxTokens; this.refillRate = refillRate; this.tokens = maxTokens; this.lastRefill = Date.now(); } async waitForToken(): Promise<void> { this.refill(); if (this.tokens < 1) { const waitTime = (1 / this.refillRate) * 1000; await new Promise(resolve => setTimeout(resolve, waitTime)); this.refill(); } this.tokens -= 1; } private refill() { const now = Date.now(); const elapsed = (now - this.lastRefill) / 1000; this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate); this.lastRefill = now; } } // Limit to 400 req/s (80% of MessageBird's 500 req/s limit for safety) export const messagebirdRateLimiter = new RateLimiter(400, 400);Use in scheduler:
await messagebirdRateLimiter.waitForToken(); const result = await sendSMS(msg.recipient, msg.message);6. Build Next.js API Routes for SMS Management
Create RESTful API endpoints to schedule SMS messages, retrieve message status, and cancel pending reminders.
API Authentication & Authorization:
For production, implement authentication:
// src/lib/auth.ts import { NextRequest } from 'next/server'; export function validateApiKey(request: NextRequest): boolean { const apiKey = request.headers.get('x-api-key'); const validKey = process.env.API_SECRET_KEY; if (!validKey) { throw new Error('API_SECRET_KEY not configured'); } return apiKey === validKey; } // Middleware for protected routes export function requireAuth(handler: Function) { return async (request: NextRequest, context: any) => { if (!validateApiKey(request)) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } return handler(request, context); }; }Request Validation with Zod:
Use schema validators for robust input validation:
// src/lib/validation.ts import { z } from 'zod'; export const createScheduleSchema = z.object({ recipient: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid E.164 phone number format'), message: z.string().min(1).max(1600, 'Message too long'), sendAt: z.string().datetime('Invalid ISO 8601 datetime'), }); export type CreateScheduleInput = z.infer<typeof createScheduleSchema>;Create Schedule Endpoint (
src/app/api/schedule/route.ts):// src/app/api/schedule/route.ts import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabase'; import { createScheduleSchema } from '@/lib/validation'; export async function POST(request: NextRequest) { try { const body = await request.json(); // Validate input const validation = createScheduleSchema.safeParse(body); if (!validation.success) { return NextResponse.json( { error: 'Validation failed', details: validation.error.errors }, { status: 400 } ); } const { recipient, message, sendAt } = validation.data; // Ensure sendAt is in the future const sendAtDate = new Date(sendAt); if (sendAtDate <= new Date()) { return NextResponse.json( { error: 'Scheduled time must be in the future' }, { status: 400 } ); } // Insert into Supabase const { data, error } = await supabaseAdmin .from('scheduled_messages') .insert({ recipient, message, send_at: sendAtDate.toISOString(), status: 'PENDING', }) .select() .single(); if (error) throw error; return NextResponse.json( { message: 'SMS scheduled successfully', scheduleId: data.id, details: data, }, { status: 201 } ); } catch (error: any) { console.error('Error in POST /api/schedule:', error); return NextResponse.json( { error: error.message || 'Internal server error' }, { status: 500 } ); } }Get Schedule Status Endpoint (
src/app/api/schedule/[id]/route.ts):// src/app/api/schedule/[id]/route.ts import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabase'; export async function GET( request: NextRequest, { params }: { params: { id: string } } ) { try { const { id } = params; const { data, error } = await supabaseAdmin .from('scheduled_messages') .select('*') .eq('id', id) .single(); if (error || !data) { return NextResponse.json( { error: 'Scheduled message not found' }, { status: 404 } ); } return NextResponse.json(data); } catch (error: any) { console.error(`Error in GET /api/schedule/${params.id}:`, error); return NextResponse.json( { error: error.message || 'Internal server error' }, { status: 500 } ); } } export async function DELETE( request: NextRequest, { params }: { params: { id: string } } ) { try { const { id } = params; // Check if message exists and is pending const { data: existing, error: fetchError } = await supabaseAdmin .from('scheduled_messages') .select('status') .eq('id', id) .single(); if (fetchError || !existing) { return NextResponse.json( { error: 'Scheduled message not found' }, { status: 404 } ); } if (existing.status !== 'PENDING') { return NextResponse.json( { error: `Cannot cancel message with status: ${existing.status}` }, { status: 400 } ); } // Update status to CANCELED const { data, error } = await supabaseAdmin .from('scheduled_messages') .update({ status: 'CANCELED', updated_at: new Date().toISOString() }) .eq('id', id) .select() .single(); if (error) throw error; return NextResponse.json({ message: 'Scheduled message canceled successfully', details: data, }); } catch (error: any) { console.error(`Error in DELETE /api/schedule/${params.id}:`, error); return NextResponse.json( { error: error.message || 'Internal server error' }, { status: 500 } ); } }API Rate Limiting (Application-Level):
Protect your API from abuse:
// src/middleware.ts (Next.js middleware) import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const rateLimit = new Map<string, { count: number; resetTime: number }>(); export function middleware(request: NextRequest) { const ip = request.ip || request.headers.get('x-forwarded-for') || 'unknown'; const now = Date.now(); const limit = 100; // requests per window const windowMs = 15 * 60 * 1000; // 15 minutes const record = rateLimit.get(ip); if (!record || now > record.resetTime) { rateLimit.set(ip, { count: 1, resetTime: now + windowMs }); } else if (record.count >= limit) { return NextResponse.json( { error: 'Too many requests' }, { status: 429 } ); } else { record.count++; } return NextResponse.next(); } export const config = { matcher: '/api/:path*', };CORS Configuration for Frontend Integration:
// src/app/api/schedule/route.ts (add to each API route) export async function OPTIONS() { return new NextResponse(null, { status: 200, headers: { 'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN || '*', 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key', }, }); }Security Middleware with Helmet.js:
For production, add security headers:
Create custom Next.js middleware:
// src/middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { const response = NextResponse.next(); // Security headers response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-XSS-Protection', '1; mode=block'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); response.headers.set( 'Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';" ); return response; }Production Logging Setup:
Implement structured logging with log levels and rotation:
// src/lib/logger.ts import winston from 'winston'; const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), defaultMeta: { service: 'sms-scheduler' }, transports: [ new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), new winston.transports.File({ filename: 'logs/combined.log' }), ], }); if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple(), })); } export default logger;7. Testing Your SMS Scheduler
Comprehensive Testing Strategy:
Example: Unit Test with Jest:
// __tests__/validation.test.ts import { createScheduleSchema } from '@/lib/validation'; describe('createScheduleSchema', () => { it('should validate correct input', () => { const valid = { recipient: '+14155552671', message: 'Test message', sendAt: '2025-12-31T23:59:59Z', }; expect(() => createScheduleSchema.parse(valid)).not.toThrow(); }); it('should reject invalid phone number', () => { const invalid = { recipient: 'invalid', message: 'Test', sendAt: '2025-12-31T23:59:59Z', }; expect(() => createScheduleSchema.parse(invalid)).toThrow(); }); });API Testing with curl:
# Schedule a message curl -X POST http://localhost:3000/api/schedule \ -H "Content-Type: application/json" \ -H "x-api-key: your_secret_key" \ -d '{ "recipient": "+14155552671", "message": "Reminder: Your appointment is tomorrow at 3 PM", "sendAt": "2025-10-15T15:00:00Z" }' # Get message status curl -X GET http://localhost:3000/api/schedule/{id} \ -H "x-api-key: your_secret_key" # Cancel message curl -X DELETE http://localhost:3000/api/schedule/{id} \ -H "x-api-key: your_secret_key"8. Deploying Your SMS Scheduling App
Environment Setup:
Database Migration Strategy:
# Run migrations on production Supabase supabase link --project-ref your-project-ref supabase db pushMonitoring & Observability:
Scaling Strategies:
9. Troubleshooting Common SMS Scheduling Issues
Common Errors & Solutions:
+[country code][number], e.g.,+14155552671MESSAGEBIRD_API_KEYin.env.local10. Best Practices for Production SMS Scheduling
Timezone Handling:
2025-10-15T15:00:00ZScheduling Edge Cases:
Delivery Receipt Handling:
Create webhook endpoint to update message status:
// src/app/api/webhooks/messagebird/route.ts import { NextRequest, NextResponse } from 'next/server'; import { supabaseAdmin } from '@/lib/supabase'; export async function POST(request: NextRequest) { try { const body = await request.json(); // MessageBird sends: { id, reference, recipient, status, statusDatetime, ... } const { id: messagebirdId, status, statusReason } = body; // Map MessageBird status to our status const ourStatus = status === 'delivered' ? 'SENT' : 'FAILED'; // Update message in database await supabaseAdmin .from('scheduled_messages') .update({ status: ourStatus, error_message: statusReason, updated_at: new Date().toISOString(), }) .eq('messagebird_id', messagebirdId); return NextResponse.json({ received: true }); } catch (error: any) { console.error('Webhook error:', error); return NextResponse.json({ error: error.message }, { status: 500 }); } }Monitoring & Alerting:
Performance Optimization:
select()to fetch only needed columnsBulk Scheduling Endpoint:
Add endpoint for scheduling multiple messages at once:
// src/app/api/schedule/bulk/route.ts export async function POST(request: NextRequest) { const { messages } = await request.json(); // Array of { recipient, message, sendAt } // Validate each message const validatedMessages = messages.map((msg: any) => createScheduleSchema.parse(msg) ); // Insert in batch const { data, error } = await supabaseAdmin .from('scheduled_messages') .insert(validatedMessages.map(msg => ({ recipient: msg.recipient, message: msg.message, send_at: msg.sendAt, status: 'PENDING', }))) .select(); if (error) throw error; return NextResponse.json({ message: `${data.length} messages scheduled`, scheduleIds: data.map(d => d.id) }); }List Schedules with Pagination:
// src/app/api/schedule/list/route.ts export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const page = parseInt(searchParams.get('page') || '1'); const limit = parseInt(searchParams.get('limit') || '20'); const status = searchParams.get('status') || undefined; const offset = (page - 1) * limit; let query = supabaseAdmin .from('scheduled_messages') .select('*', { count: 'exact' }) .range(offset, offset + limit - 1) .order('created_at', { ascending: false }); if (status) { query = query.eq('status', status); } const { data, error, count } = await query; if (error) throw error; return NextResponse.json({ data, pagination: { page, limit, total: count, totalPages: Math.ceil((count || 0) / limit), }, }); }Conclusion: Your MessageBird SMS Scheduler is Ready
You've successfully built a production-ready SMS scheduling system using MessageBird API, Next.js, and Supabase Cron. Your system includes:
Next Steps:
Resources:
For production deployment, review MessageBird's country-specific regulations and ensure compliance with SMS messaging laws (TCPA in USA, GDPR in EU, etc.).