A minimal, production-ready bulk email campaign tool built with Next.js + SendGrid + SQLite.
npm installcp .env.example .envEdit .env and fill in:
SENDGRID_API_KEY— your SendGrid API keySENDER_EMAIL— verified sender email in SendGridSENDER_NAME— display name (optional)DATABASE_URL— keep asfile:./dev.dbfor local SQLite
npm run db:migrateOr for a faster first-time setup (skips migration history):
npm run db:pushnpm run dev-
Upload your email list — click "Upload CSV" and select a
.csvfile. Any column containing@is treated as email addresses. Duplicates are automatically skipped. -
Compose your campaign — fill in the subject line and email body.
-
Send — click "Send Email Campaign". The campaign starts immediately in the background and status updates in real time.
Any of these work:
# Single column
email@example.com
another@example.com
# With header
email,name
user@example.com,Alice
# Semicolon or tab separated
user1@example.com;John
user2@example.com Jane
app/
page.tsx # Single-page dashboard (client component)
layout.tsx # Root layout
api/
emails/route.ts # GET (count) + POST (CSV upload)
campaigns/
route.ts # GET (list) + POST (create + start)
[id]/route.ts # GET (poll status)
components/
CampaignStatus.tsx # Live-polling campaign list
lib/
prisma.ts # Prisma client singleton
sendgrid.ts # SendGrid sender + HTML template builder
csv-parser.ts # Zero-dependency CSV email extractor
worker.ts # Background campaign processor
prisma/
schema.prisma # Email + Campaign models
- User clicks "Send Email Campaign"
- API creates a
Campaignrecord (status:queued) processCampaign()runs asynchronously in the background- Emails are fetched in batches of 500 using cursor pagination
- Each batch is sent via SendGrid (parallel within batch)
- 1.5 second delay between batches (avoids rate limits)
sent_countis updated after each batch- UI polls
/api/campaignsevery 2 seconds while a campaign is active
| Recipients | Estimated time |
|---|---|
| 10,000 | ~30 seconds |
| 100,000 | ~5 minutes |
| 500,000 | ~25 minutes |
For 500k+ recipients, use Railway or Render (long-running process). Vercel functions time out at 10 minutes.
# Install Railway CLI
npm install -g @railway/cli
railway login
railway init
railway up
# Set environment variables in Railway dashboard
# DATABASE_URL: file:./prisma/prod.db (or use Railway's Postgres)- Create a new Web Service pointing to this repo
- Build command:
npm install && npm run db:push && npm run build - Start command:
npm start - Add environment variables in Render dashboard
npm install -g vercel
vercel
# Add env vars:
vercel env add SENDGRID_API_KEY
vercel env add SENDER_EMAIL
vercel env add DATABASE_URLNote: Vercel serverless functions have a max duration of 10 minutes. For large lists, the worker will be killed mid-send. Use Railway or Render for 100k+ recipients.
| Variable | Required | Description |
|---|---|---|
SENDGRID_API_KEY |
Yes | SendGrid API key (starts with SG.) |
SENDER_EMAIL |
Yes | Verified sender email address |
SENDER_NAME |
No | Display name (default: "Newsletter") |
DATABASE_URL |
Yes | SQLite path, e.g. file:./dev.db |
- Email addresses are stored with a
UNIQUEconstraint — CSV re-uploads skip existing addresses automatically - Each campaign sends to the full list at time of creation via cursor-based pagination
Failed sends are tracked in failedCount on the campaign. To retry failed emails, create a new campaign — in a future iteration you could add a CampaignRecipient join table to track per-email status and retry only failures.