npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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-kit

Install 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-js

Quick 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_xxxxxxxxxxxx

Then 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