@volchoklv/newsletter-kit
v1.1.1
Published
Drop-in newsletter subscription components and API handlers for Next.js with adapter support for email providers and storage backends
Maintainers
Readme
@volchoklv/newsletter-kit
Drop-in newsletter subscription components and API handlers for Next.js with adapter support for email providers and storage backends.
Features
- 🎨 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
Installation
npm install @volchoklv/newsletter-kit
# or
pnpm add @volchoklv/newsletter-kit
# or
yarn add @volchoklv/newsletter-kitInstall peer dependencies based on your choices:
# For Resend
npm install resend
# For Nodemailer/SMTP
npm install nodemailer
# For Prisma storage
npm install @prisma/client
# For Supabase storage
npm install @supabase/supabase-jsQuick Start
1. Create the newsletter handler
// 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: '[email protected]',
adminEmail: '[email protected]', // Get notified of new subscribers
}),
storageAdapter: createPrismaAdapter({ prisma }),
baseUrl: process.env.NEXT_PUBLIC_URL!,
doubleOptIn: true,
honeypotField: 'website',
rateLimit: {
max: 5,
windowSeconds: 60,
},
});2. Create API routes
// app/api/newsletter/subscribe/route.ts
import { newsletter } from '@/lib/newsletter';
export const POST = newsletter.subscribe;// app/api/newsletter/confirm/route.ts
import { newsletter } from '@/lib/newsletter';
export const GET = newsletter.confirm;// app/api/newsletter/unsubscribe/route.ts
import { newsletter } from '@/lib/newsletter';
export const POST = newsletter.unsubscribe;
export const GET = newsletter.unsubscribe;3. Add the form component
// components/footer.tsx
import { NewsletterForm } from '@volchoklv/newsletter-kit/components';
export function Footer() {
return (
<footer>
<NewsletterForm
endpoint="/api/newsletter/subscribe"
source="footer"
placeholder="Enter your email"
buttonText="Subscribe"
/>
</footer>
);
}4. Add Prisma schema
PostgreSQL / Neon / MySQL:
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:
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
NewsletterForm
Basic form component with full customization:
<NewsletterForm
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"
/>NewsletterBlock
Full-width section for landing pages:
<NewsletterBlock
endpoint="/api/newsletter/subscribe"
title="Stay in the loop"
description="Get weekly updates on the latest features and news."
source="landing-page"
/>NewsletterCard
Card variant for sidebars:
<NewsletterCard
endpoint="/api/newsletter/subscribe"
title="Newsletter"
description="Don't miss out on updates."
source="sidebar"
/>NewsletterFooter
Optimized for site footers:
<NewsletterFooter
endpoint="/api/newsletter/subscribe"
title="Newsletter"
description="Stay up to date."
source="footer"
privacyText="We respect your privacy."
privacyLink="/privacy"
/>NewsletterModalContent
For use in dialogs/modals:
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog';
import { NewsletterModalContent } from '@volchoklv/newsletter-kit/components';
export function NewsletterModal() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Subscribe</Button>
</DialogTrigger>
<DialogContent>
<NewsletterModalContent
endpoint="/api/newsletter/subscribe"
source="popup"
onSuccess={() => setTimeout(() => setOpen(false), 2000)}
/>
</DialogContent>
</Dialog>
);
}Hook
For custom implementations:
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 (
<form onSubmit={(e) => {
e.preventDefault();
subscribe(e.currentTarget.email.value);
}}>
{/* Your custom UI */}
</form>
);
}Email Adapters
Resend
import { createResendAdapter } from '@volchoklv/newsletter-kit/adapters/email/resend';
const emailAdapter = createResendAdapter({
apiKey: process.env.RESEND_API_KEY!,
from: '[email protected]',
replyTo: '[email protected]',
adminEmail: '[email protected]',
templates: {
confirmation: {
subject: 'Please confirm your subscription',
html: ({ confirmUrl, email }) => `
<h1>Confirm your subscription</h1>
<a href="${confirmUrl}">Click here to confirm</a>
`,
},
welcome: {
subject: 'Welcome aboard! 🎉',
html: ({ email }) => `
<h1>You're subscribed!</h1>
<p>Thanks for joining us.</p>
`,
},
},
});Syncing with Resend Audiences
To send newsletters via Resend Broadcasts, sync confirmed subscribers to a Resend Audience:
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: '[email protected]',
}),
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_xxxxxxxxxxxxThen use Resend's Broadcast feature to send newsletters to your audience.
Nodemailer (SMTP)
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: '[email protected]',
});Mailchimp
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: '[email protected]',
useAsStorage: true, // Use Mailchimp as storage too
});Storage Adapters
Prisma
import { createPrismaAdapter } from '@volchoklv/newsletter-kit/adapters/storage/prisma';
import { prisma } from '@/lib/prisma';
const storageAdapter = createPrismaAdapter({ prisma });Supabase
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 });In-Memory (Development/Testing)
import { createMemoryAdapter } from '@volchoklv/newsletter-kit/adapters/storage/memory';
const storageAdapter = createMemoryAdapter();No Storage (Email Only)
When using Mailchimp or similar that handles storage:
import { createNoopAdapter } from '@volchoklv/newsletter-kit/adapters/storage/memory';
const storageAdapter = createNoopAdapter();Configuration
Full configuration options:
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
Server Methods
// 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 protected]')Tailwind CSS
The components use Tailwind CSS with shadcn/ui-compatible class names. Make sure your tailwind.config.js includes:
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.
Author
Volchok - volchok.dev
License
MIT
