Drop-in newsletter subscription components and API handlers for Next.js with adapter support for email providers and storage backends
npm install @volchoklv/newsletter-kitDrop-in newsletter subscription components and API handlers for Next.js with adapter support for email providers and storage backends.
- 🎨 shadcn/ui compatible - Styled components that match your design system
- 🔌 Adapter pattern - Swap email providers and storage backends without changing code
- 🛡️ Built-in protection - Honeypot bot detection, rate limiting, email validation
- 📊 Source tracking - Track where subscribers come from
- ✅ Double opt-in - Optional email confirmation flow
- 🎯 TypeScript first - Full type safety
``bash`
npm install @volchoklv/newsletter-kitor
pnpm add @volchoklv/newsletter-kitor
yarn add @volchoklv/newsletter-kit
Install peer dependencies based on your choices:
`bashFor Resend
npm install resend
Quick Start
$3
`ts
// lib/newsletter.ts
import { createNewsletterHandlers } from '@volchoklv/newsletter-kit/server';
import { createResendAdapter } from '@volchoklv/newsletter-kit/adapters/email/resend';
import { createPrismaAdapter } from '@volchoklv/newsletter-kit/adapters/storage/prisma';
import { prisma } from '@/lib/prisma';export const newsletter = createNewsletterHandlers({
emailAdapter: createResendAdapter({
apiKey: process.env.RESEND_API_KEY!,
from: 'newsletter@yourdomain.com',
adminEmail: 'you@yourdomain.com', // Get notified of new subscribers
}),
storageAdapter: createPrismaAdapter({ prisma }),
baseUrl: process.env.NEXT_PUBLIC_URL!,
doubleOptIn: true,
honeypotField: 'website',
rateLimit: {
max: 5,
windowSeconds: 60,
},
});
`$3
`ts
// app/api/newsletter/subscribe/route.ts
import { newsletter } from '@/lib/newsletter';export const POST = newsletter.subscribe;
``ts
// app/api/newsletter/confirm/route.ts
import { newsletter } from '@/lib/newsletter';export const GET = newsletter.confirm;
``ts
// app/api/newsletter/unsubscribe/route.ts
import { newsletter } from '@/lib/newsletter';export const POST = newsletter.unsubscribe;
export const GET = newsletter.unsubscribe;
`$3
`tsx
// components/footer.tsx
import { NewsletterForm } from '@volchoklv/newsletter-kit/components';export function Footer() {
return (
);
}
`$3
PostgreSQL / Neon / MySQL:
`prisma
model NewsletterSubscriber {
id String @id @default(cuid())
email String @unique
status String @default("pending")
token String? @unique
source String?
tags String[] @default([])
metadata Json?
consentIp String?
consentAt DateTime?
confirmedAt DateTime?
unsubscribedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @@index([status])
@@index([source])
}
`MongoDB:
`prisma
model NewsletterSubscriber {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String @unique
status String @default("pending")
token String? @unique
source String?
tags String[] @default([])
metadata Json?
consentIp String?
consentAt DateTime?
confirmedAt DateTime?
unsubscribedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @@index([status])
@@index([source])
}
`> Note: The adapter code works identically for both — Prisma handles the database differences.
Components
$3
Basic form component with full customization:
`tsx
endpoint="/api/newsletter/subscribe"
source="homepage"
tags={['marketing', 'product-updates']}
placeholder="Enter your email"
buttonText="Subscribe"
loadingText="Subscribing..."
successMessage="Check your inbox to confirm!"
onSuccess={(email, message) => console.log('Subscribed:', email)}
onError={(error) => console.error('Error:', error)}
className="max-w-md"
inputClassName="border-2"
buttonClassName="bg-blue-600 hover:bg-blue-700"
/>
`$3
Full-width section for landing pages:
`tsx
endpoint="/api/newsletter/subscribe"
title="Stay in the loop"
description="Get weekly updates on the latest features and news."
source="landing-page"
/>
`$3
Card variant for sidebars:
`tsx
endpoint="/api/newsletter/subscribe"
title="Newsletter"
description="Don't miss out on updates."
source="sidebar"
/>
`$3
Optimized for site footers:
`tsx
endpoint="/api/newsletter/subscribe"
title="Newsletter"
description="Stay up to date."
source="footer"
privacyText="We respect your privacy."
privacyLink="/privacy"
/>
`$3
For use in dialogs/modals:
`tsx
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { NewsletterModalContent } from '@volchoklv/newsletter-kit/components';export function NewsletterModal() {
const [open, setOpen] = useState(false);
return (
);
}
`Hook
For custom implementations:
`tsx
import { useNewsletter } from '@volchoklv/newsletter-kit/components';export function CustomForm() {
const { subscribe, isLoading, isSuccess, isError, message } = useNewsletter({
endpoint: '/api/newsletter/subscribe',
source: 'custom',
onSuccess: (email) => {
// Track in analytics
},
});
return (
);
}
`Email Adapters
$3
`ts
import { createResendAdapter } from '@volchoklv/newsletter-kit/adapters/email/resend';const emailAdapter = createResendAdapter({
apiKey: process.env.RESEND_API_KEY!,
from: 'newsletter@yourdomain.com',
replyTo: 'hello@yourdomain.com',
adminEmail: 'you@yourdomain.com',
templates: {
confirmation: {
subject: 'Please confirm your subscription',
html: ({ confirmUrl, email }) =>
,
},
welcome: {
subject: 'Welcome aboard! 🎉',
html: ({ email }) => Thanks for joining us.
,
},
},
});
`$3
To send newsletters via Resend Broadcasts, sync confirmed subscribers to a Resend Audience:
`ts
import { createNewsletterHandlers } from '@volchoklv/newsletter-kit/server';
import { createResendAdapter } from '@volchoklv/newsletter-kit/adapters/email/resend';
import { createPrismaAdapter } from '@volchoklv/newsletter-kit/adapters/storage/prisma';
import { Resend } from 'resend';
import { prisma } from '@/lib/prisma';const resend = new Resend(process.env.RESEND_API_KEY!);
export const newsletter = createNewsletterHandlers({
emailAdapter: createResendAdapter({
apiKey: process.env.RESEND_API_KEY!,
from: 'newsletter@yourdomain.com',
}),
storageAdapter: createPrismaAdapter({ prisma }),
baseUrl: process.env.NEXT_PUBLIC_APP_URL!,
doubleOptIn: true,
// Sync to Resend Audience on confirmation
onConfirm: async (subscriber) => {
await resend.contacts.create({
audienceId: process.env.RESEND_AUDIENCE_ID!,
email: subscriber.email,
unsubscribed: false,
});
},
// Mark unsubscribed in Resend Audience
onUnsubscribe: async (email) => {
const { data } = await resend.contacts.list({
audienceId: process.env.RESEND_AUDIENCE_ID!,
});
const contact = data?.data?.find((c) => c.email === email);
if (contact) {
await resend.contacts.update({
audienceId: process.env.RESEND_AUDIENCE_ID!,
id: contact.id,
unsubscribed: true,
});
}
},
});
`Create an audience at resend.com/audiences and add the Audience ID to your environment:
`
RESEND_AUDIENCE_ID=aud_xxxxxxxxxxxx
`Then use Resend's Broadcast feature to send newsletters to your audience.
$3
`ts
import { createNodemailerAdapter } from '@volchoklv/newsletter-kit/adapters/email/nodemailer';const emailAdapter = createNodemailerAdapter({
smtp: {
host: 'smtp.example.com',
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
},
},
from: 'newsletter@yourdomain.com',
});
`$3
`ts
import { createMailchimpAdapter } from '@volchoklv/newsletter-kit/adapters/email/mailchimp';const emailAdapter = createMailchimpAdapter({
apiKey: process.env.MAILCHIMP_API_KEY!,
server: 'us1',
listId: 'your-list-id',
from: 'newsletter@yourdomain.com',
useAsStorage: true, // Use Mailchimp as storage too
});
`Storage Adapters
$3
`ts
import { createPrismaAdapter } from '@volchoklv/newsletter-kit/adapters/storage/prisma';
import { prisma } from '@/lib/prisma';const storageAdapter = createPrismaAdapter({ prisma });
`$3
`ts
import { createSupabaseAdapter } from '@volchoklv/newsletter-kit/adapters/storage/supabase';
import { createClient } from '@supabase/supabase-js';const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
const storageAdapter = createSupabaseAdapter({ supabase });
`$3
`ts
import { createMemoryAdapter } from '@volchoklv/newsletter-kit/adapters/storage/memory';const storageAdapter = createMemoryAdapter();
`$3
When using Mailchimp or similar that handles storage:
`ts
import { createNoopAdapter } from '@volchoklv/newsletter-kit/adapters/storage/memory';const storageAdapter = createNoopAdapter();
`Configuration
Full configuration options:
`ts
const newsletter = createNewsletterHandlers({
// Required
emailAdapter: createResendAdapter({ ... }),
baseUrl: 'https://yourdomain.com', // Optional storage (defaults to in-memory)
storageAdapter: createPrismaAdapter({ prisma }),
// Double opt-in (default: true)
doubleOptIn: true,
// API paths (defaults shown)
confirmPath: '/api/newsletter/confirm',
unsubscribePath: '/api/newsletter/unsubscribe',
// Bot protection
honeypotField: 'website',
// Rate limiting
rateLimit: {
max: 5,
windowSeconds: 60,
},
// Custom email validation
validateEmail: async (email) => {
// Block disposable emails, etc.
return !email.includes('tempmail.com');
},
// Allowed sources for tracking
allowedSources: ['footer', 'sidebar', 'popup', 'landing-page'],
// Default tags for all subscribers
defaultTags: ['newsletter'],
// Callbacks
onSubscribe: async (subscriber) => {
// Track in analytics
},
onConfirm: async (subscriber) => {
// Send to CRM
},
onUnsubscribe: async (email) => {
// Update CRM
},
onError: async (error, context) => {
// Log to error tracking
},
});
`API Reference
$3
`ts
// Route handlers
newsletter.subscribe // POST handler
newsletter.confirm // GET handler
newsletter.unsubscribe // POST/GET handler// Direct access
newsletter.handlers.subscribe(req)
newsletter.handlers.confirm(token)
newsletter.handlers.unsubscribe(email)
// Storage access
newsletter.storage.listSubscribers({ status: 'confirmed' })
newsletter.getSubscriber('email@example.com')
`Tailwind CSS
The components use Tailwind CSS with shadcn/ui-compatible class names. Make sure your
tailwind.config.js includes:`js
module.exports = {
content: [
// ...
'./node_modules/@volchoklv/newsletter-kit/*/.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
},
},
};
`Or use the
className` props to apply your own styles.Volchok - volchok.dev
MIT