A newsletter is one of the few marketing channels you fully own. If your site already uses Firebase, you can store subscribers in Firestore and run sign-up, confirmation and unsubscribe flows from Next.js without a separate marketing platform. This guide walks through a setup that is simple, secure and respectful of your readers' consent.
One thing to know up front: Firebase does not send email by itself. Firestore stores your subscribers; the emails go out through an email provider, either directly from your server or through Firebase's Trigger Email extension.
Key takeaways
- Store subscribers in Firestore with a status field (
pending,active,unsubscribed) instead of deleting records. - Use double opt-in: send a confirmation link and only mail people who click it.
- Write to Firestore from the server (Next.js route handlers with the Admin SDK) and lock down client access with security rules.
- Send email through a transactional email provider or the Trigger Email extension, and include a one-click unsubscribe link in every message.
- Collect clear consent and keep a record of it, in line with India's DPDP Act and similar laws.
1. Design the Firestore data model
Use the normalised email address as the document ID. This prevents duplicates without extra queries.
subscribers/{email}
email: string
status: "pending" | "active" | "unsubscribed"
token: string // random token for confirm and unsubscribe links
source: string // e.g. "footer", "blog"
consentText: string // the exact wording the user agreed to
createdAt: timestamp
confirmedAt: timestamp | null
unsubscribedAt: timestamp | null
Keeping unsubscribed records (instead of deleting them) means you never accidentally re-add someone who opted out, and you have a history if you need to show how consent was collected.
2. Set up Firebase on the server
Writes should happen on the server, where you can validate input and keep secrets. Initialise the Firebase Admin SDK once and reuse it.
// src/lib/firebase-admin.ts
import { cert, getApps, initializeApp } from 'firebase-admin/app';
import { getFirestore } from 'firebase-admin/firestore';
const app =
getApps()[0] ??
initializeApp({
credential: cert({
projectId: process.env.FIREBASE_PROJECT_ID,
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
}),
});
export const adminDb = getFirestore(app);
Store the service account values as environment variables in your hosting platform, never in the repository.
3. Handle sign-ups with a route handler
The subscribe endpoint validates the email, creates or refreshes a pending record with a fresh token, and sends a confirmation email.
// src/app/api/newsletter/subscribe/route.ts
import { randomBytes } from 'node:crypto';
import { NextResponse } from 'next/server';
import { FieldValue } from 'firebase-admin/firestore';
import { adminDb } from '@/lib/firebase-admin';
import { sendEmail } from '@/lib/email';
const CONSENT = 'I agree to receive the Twigo newsletter. I can unsubscribe at any time.';
export async function POST(req: Request) {
const { email, source } = await req.json().catch(() => ({}));
const normalised = String(email ?? '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalised)) {
return NextResponse.json({ ok: false, error: 'Please enter a valid email.' }, { status: 400 });
}
const ref = adminDb.collection('subscribers').doc(normalised);
const existing = await ref.get();
if (existing.exists && existing.get('status') === 'active') {
return NextResponse.json({ ok: true }); // don't reveal who is subscribed
}
const token = randomBytes(24).toString('base64url');
await ref.set(
{
email: normalised,
status: 'pending',
token,
source: String(source ?? 'website'),
consentText: CONSENT,
createdAt: FieldValue.serverTimestamp(),
confirmedAt: null,
unsubscribedAt: null,
},
{ merge: true },
);
const confirmUrl = `${process.env.SITE_URL}/api/newsletter/confirm?email=${encodeURIComponent(normalised)}&token=${token}`;
await sendEmail({
to: normalised,
subject: 'Please confirm your subscription',
html: `<p>Click to confirm your subscription:</p><p><a href="${confirmUrl}">Confirm subscription</a></p>`,
});
return NextResponse.json({ ok: true });
}
Add rate limiting to this route (per IP) so bots cannot use it to send confirmation emails to random addresses.
4. Confirm and unsubscribe with tokens
Both links carry the email and token. The handler compares the token before changing the status.
// src/app/api/newsletter/confirm/route.ts
import { NextResponse } from 'next/server';
import { FieldValue } from 'firebase-admin/firestore';
import { adminDb } from '@/lib/firebase-admin';
export async function GET(req: Request) {
const url = new URL(req.url);
const email = url.searchParams.get('email')?.toLowerCase() ?? '';
const token = url.searchParams.get('token') ?? '';
const ref = adminDb.collection('subscribers').doc(email);
const snap = await ref.get();
if (!snap.exists || snap.get('token') !== token) {
return NextResponse.redirect(new URL('/newsletter/invalid-link', url));
}
await ref.update({ status: 'active', confirmedAt: FieldValue.serverTimestamp() });
return NextResponse.redirect(new URL('/newsletter/confirmed', url));
}
The unsubscribe route is the same pattern with status: 'unsubscribed' and unsubscribedAt. Put the unsubscribe link in every email, and also send List-Unsubscribe headers so mail apps can show their built-in unsubscribe button. Large mailbox providers now expect one-click unsubscribe for bulk senders.
5. Send the emails
You have two practical options.
Option A: call a transactional email provider from your server. Wrap your provider's SDK (for example Amazon SES, Postmark, SendGrid or Resend) in one function so the rest of the code does not depend on it.
// src/lib/email.ts
type Email = { to: string; subject: string; html: string; headers?: Record<string, string> };
export async function sendEmail(email: Email): Promise<void> {
// Replace with your provider's SDK call.
const res = await fetch(process.env.EMAIL_API_URL!, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.EMAIL_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: 'Twigo <newsletter@twigo.in>', ...email }),
});
if (!res.ok) throw new Error(`Email send failed: ${res.status}`);
}
Option B: the Trigger Email extension. Install the Firebase "Trigger Email from Firestore" extension, connect it to an SMTP provider, and send an email by writing a document to its collection:
await adminDb.collection('mail').add({
to: 'reader@example.com',
message: { subject: 'New on the Twigo blog', html: '<p>Read our latest guide…</p>' },
});
To send an issue, query subscribers where status == "active" and send in batches from a background job or Cloud Function, not inside a page request. Authenticate your sending domain with SPF, DKIM and DMARC so your messages reach the inbox.
6. Lock down Firestore with security rules
Because all writes go through the Admin SDK (which bypasses rules), the client needs no access at all to subscriber data:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /subscribers/{email} {
allow read, write: if false;
}
match /mail/{id} {
allow read, write: if false;
}
}
}
If you prefer to accept sign-ups directly from the browser with the web SDK, allow only the creation of a pending document with the expected fields:
import { getFirestore, doc, setDoc, serverTimestamp } from 'firebase/firestore';
const db = getFirestore();
await setDoc(doc(db, 'subscribers', email), { email, status: 'pending', source: 'footer', createdAt: serverTimestamp() });
match /subscribers/{email} {
allow create: if request.resource.data.status == 'pending'
&& request.resource.data.email == email
&& request.resource.data.keys().hasOnly(['email', 'status', 'source', 'createdAt']);
allow read, update, delete: if false;
}
You would then generate the token and send the confirmation email from a Cloud Function triggered by the new document.
7. Consent and privacy
India's Digital Personal Data Protection (DPDP) Act requires clear, specific consent and an easy way to withdraw it. In practice:
- Show plain-language consent text next to the sign-up box and store the exact wording with each record.
- Use double opt-in so you can show that the address owner agreed.
- Honour unsubscribes immediately and never re-add those addresses.
- Collect only what you need (usually just the email) and say how you use it in your privacy policy.
This is general guidance, not legal advice; check the rules that apply to your business.
8. Measure what matters
Track confirmed sign-ups by source, unsubscribe rate per issue, and clicks on links (with UTM parameters so they show up in analytics). Open rates are less reliable than they used to be because many mail apps preload images, so treat clicks and replies as the stronger signal.
Building the rest of your stack? Our guide to API optimization covers patterns that apply to these routes too.
Frequently asked questions
Can Firebase send emails on its own?
No, Firebase does not include a general email-sending service for newsletters. Firestore can store your subscribers, but the emails must go through an email provider. You can call a transactional email service from your server or Cloud Functions, or install the Trigger Email extension, which sends messages through an SMTP provider when you add documents to a collection.
How do I send newsletter emails from Firebase?
Install the Trigger Email from Firestore extension and connect it to an SMTP provider, then write one document per email to its collection with the recipient and message. Alternatively, run a Cloud Function or server job that reads active subscribers from Firestore and sends messages in batches through a transactional email API, including an unsubscribe link in each email.
What is double opt-in and do I need it?
Double opt-in means a new subscriber must click a confirmation link before receiving emails. It proves the address owner agreed, keeps fake and mistyped addresses off your list, and protects your sender reputation. It is strongly recommended for any newsletter and makes it much easier to demonstrate valid consent under privacy laws such as India's DPDP Act.
How do I add an unsubscribe link to Firebase emails?
Generate a random token for each subscriber, store it in their Firestore document, and add a link containing the email and token to every email. A server route checks the token and sets the status to unsubscribed. Also send List-Unsubscribe headers so email apps can show a one-click unsubscribe button.
Should I write to Firestore from the client or the server?
Write subscriber data from the server whenever possible. Server routes using the Firebase Admin SDK can validate input, apply rate limits and keep tokens secret, while security rules block all client access. If you must write from the browser, restrict rules so clients can only create pending records with a fixed set of fields.
How do I stop bots from signing up to my newsletter?
Rate limit the subscribe endpoint per IP address, validate email format on the server, and use double opt-in so unconfirmed addresses never receive newsletters. You can also add a hidden honeypot field that real users leave empty, or a lightweight challenge such as Cloudflare Turnstile or reCAPTCHA if abuse continues.
Is Firestore a good database for a newsletter list?
Firestore works well for small and medium newsletter lists, especially if your app already uses Firebase. It handles sign-ups and status changes easily. For very large lists, advanced segmentation or campaign analytics, a dedicated email marketing platform may be simpler, and you can still sync subscribers to it from Firestore.
Want this built for your site?
Twigo builds newsletter, sign-up and notification systems for business websites, with secure data handling and reliable email delivery. See our web development services.




