A Sanity plugin for newsletter management with Resend integration. Create and send email campaigns from Sanity Studio with live preview, draft support, and minimal Next.js setup.
- Studio tool: Dedicated Newsletters tool (in the Studio nav) with list, live preview, and send
- Structure integration: Add newsletters to your Content list; documents get Edit + Preview tabs
- Document preview: Preview tab updates in real time as you edit (shows draft content)
- Draft content: Preview shows unpublished changes (no Next.js draft mode required)
- Configurable schema: Document type name and content blocks are fully user-defined
- Resend integration: Send to segments/audiences via Resend Broadcasts API
- No built-in templates: You provide your own email templates and block renderers
npm install sanity-plugin-newsletter resend
# Optional: React Email for building templates (recommended)
npm install @react-email/render @react-email/components- Configure Sanity Studio – add the plugin and your content blocks
- Create API routes in your Next.js app – preview, send, audiences
- Implement
renderToHtml– your email template that converts newsletter data to HTML - Set environment variables – API keys, Resend credentials
After setup, you'll have:
your-project/
├── studio/
│ └── sanity.config.ts # newsletterPlugin + contentBlocks
│ └── schemaTypes/blocks/
│ └── newsletter/ # Your block schemas
└── app/
└── api/newsletters/
├── preview/route.ts # GET – renders newsletter HTML
├── send/route.ts # POST – sends via Resend
└── audiences/route.ts # GET – lists Resend segments
You pass a renderToHtml function to the preview and send handlers—define it inline in the routes or in a separate module.
The plugin does not ship any blocks. Define your own in your Studio schema. These are regular sanity blocks:
// studio/schemaTypes/blocks/newsletter/newsletter-block-heading.ts
import { defineField, defineType } from 'sanity';
export const newsletterBlockHeading = defineType({
name: 'newsletterBlockHeading',
title: 'Heading',
type: 'object',
fields: [
defineField({
name: 'level',
title: 'Level',
type: 'string',
options: {
list: [
{ title: 'H1', value: 'h1' },
{ title: 'H2', value: 'h2' },
{ title: 'H3', value: 'h3' },
],
layout: 'radio',
},
initialValue: 'h2',
}),
defineField({
name: 'text',
title: 'Text',
type: 'string',
validation: (rule) => rule.required(),
}),
],
});Create as many block types as you need (text, image, button, divider, spacer, etc.). Each block needs a schema with name and type: 'object'.
import { defineConfig } from 'sanity';
import { structureTool } from 'sanity/structure';
import {
newsletterPlugin,
getNewsletterDefaultDocumentNode,
} from 'sanity-plugin-newsletter';
import {
newsletterBlockHeading,
newsletterBlockText,
newsletterBlockImage,
newsletterBlockButton,
newsletterBlockDivider,
newsletterBlockSpacer,
} from './schemaTypes/blocks/newsletter';
// Optional: group block config for reuse
const contentBlocks = [
{ type: newsletterBlockHeading.name, schema: newsletterBlockHeading },
{ type: newsletterBlockText.name, schema: newsletterBlockText },
{ type: newsletterBlockImage.name, schema: newsletterBlockImage },
{ type: newsletterBlockButton.name, schema: newsletterBlockButton },
{ type: newsletterBlockDivider.name, schema: newsletterBlockDivider },
{ type: newsletterBlockSpacer.name, schema: newsletterBlockSpacer },
];
export default defineConfig({
// ...projectId, dataset, etc.
plugins: [
newsletterPlugin({
documentType: 'newsletter',
contentBlocks,
apiUrl:
process.env.SANITY_STUDIO_NEWSLETTER_API_URL || 'http://localhost:3000',
apiKey: process.env.SANITY_STUDIO_NEWSLETTER_API_KEY,
}),
structureTool({
structure: (S) =>
S.list()
.title('Content')
.items([
// ...your structure items (Places, Posts, etc.)
S.listItem()
.title('Newsletters')
.child(S.documentTypeList('newsletter').title('Newsletters')),
]),
defaultDocumentNode: getNewsletterDefaultDocumentNode(),
}),
],
});| Option | Type | Default | Description |
|---|---|---|---|
documentType |
string |
'newsletter' |
Document type name. Override if you have a conflict. |
contentBlocks |
Array<{ type: string; schema: SchemaTypeDefinition }> |
Required | Your block schemas. Each needs type (block name) and schema. |
apiUrl |
string |
process.env.SANITY_STUDIO_NEWSLETTER_API_URL or http://localhost:3000 |
Base URL of your Next.js app (for preview iframe and API calls). |
apiKey |
string |
process.env.SANITY_STUDIO_NEWSLETTER_API_KEY |
Shared secret for Studio → API authentication. |
baseUrl |
string |
— | Base URL for "view in browser" links (optional). |
Add to .env.local:
# Sanity (you likely have these)
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_API_VERSION=2025-01-01
# Newsletter plugin – shared secret for Studio ↔ API auth
NEWSLETTER_API_KEY=your-secure-random-string
# Sanity API token – for server-side fetch (preview/send)
# Create at sanity.io/manage → API → Tokens
# Needs "Viewer" for preview, "Editor" for send (to patch sentAt)
SANITY_API_READ_TOKEN=your-read-token
SANITY_API_WRITE_TOKEN=your-write-token
# Resend – for sending newsletters
RESEND_API_KEY=re_xxxxxxxxxxxx
RESEND_FROM_EMAIL=newsletter@yourdomain.com
# Optional: CORS for Studio (if Studio is on different origin)
NEXT_PUBLIC_SANITY_STUDIO_URL=http://localhost:3333Create a function that takes newsletter data and returns HTML. React Email is recommended for building responsive templates with React components, but it's not required—you can use MJML, Handlebars, or any template engine.
// lib/render-newsletter-to-html.ts
import 'server-only';
import { createElement } from 'react';
import { render } from '@react-email/render';
import imageUrlBuilder from '@sanity/image-url';
import { NewsletterEmail } from '~/emails/newsletter'; // Your template
import { hydrateNewsletterBlocks } from '~/lib/resolve-newsletter-button-href'; // If you have document links
import { projectId, dataset } from '~/sanity/lib/api';
const builder = imageUrlBuilder({ projectId, dataset });
function getImageUrl(ref: string) {
return builder.image(ref).width(600).url();
}
export async function renderNewsletterToHtml(newsletter: {
_id: string;
title?: string;
subject: string;
previewText?: string;
blocks: unknown[];
[key: string]: unknown;
}): Promise<string> {
// Hydrate blocks (e.g. resolve document references to URLs)
const rawBlocks = (newsletter.blocks || []) as YourBlockType[];
const blocks = await hydrateNewsletterBlocks(rawBlocks, { absolute: true });
const publishedId = newsletter._id.replace(/^drafts\./, '');
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://yoursite.com';
const viewInBrowserUrl = `${baseUrl}/newsletters/${publishedId}`;
const reactEmail = createElement(NewsletterEmail, {
subject: newsletter.subject,
previewText: newsletter.previewText,
blocks,
imageUrlBuilder: getImageUrl,
viewInBrowserUrl,
unsubscribeUrl: '{{{RESEND_UNSUBSCRIBE_URL}}}', // Required for Resend broadcasts
});
return render(reactEmail);
}If your newsletter blocks contain references or require a custom GROQ projection, define a blocksProjection string. You must provide this projection to your API handler (e.g., createNewsletterPreviewHandler) using the blocksProjection option, so the correct data is fetched from Sanity for each block.
Example:
const BLOCKS_PROJECTION = `blocks[]{
_type,
_key,
level,
text,
_type == "newsletterBlockHeading" => { level, text },
_type == "newsletterBlockText" => { "content": pt::text(content) },
_type == "newsletterBlockImage" => {
image { asset { _ref } },
alt,
caption
},
_type == "newsletterBlockButton" => {
label,
linkType,
href,
document->{
_type,
_id,
"slug": slug.current,
"categorySlug": category->slug.current
}
},
_type == "newsletterBlockSpacer" => { height }
}`;When creating your preview (or send) handler, pass blocksProjection as shown below:
const handler = createNewsletterPreviewHandler({
// ...other options,
blocksProjection: BLOCKS_PROJECTION,
// ...
});This ensures your API has all the necessary fields resolved and shaped for your rendering function.
// app/api/newsletters/preview/route.ts
import { createNewsletterPreviewHandler } from 'sanity-plugin-newsletter/next';
import { projectId, dataset, apiVersion } from '~/sanity/lib/api';
import { renderNewsletterToHtml } from '~/lib/render-newsletter-to-html';
const BLOCKS_PROJECTION = `blocks[]{ ... }`; // Your projection
const handler = createNewsletterPreviewHandler({
projectId,
dataset,
apiVersion,
apiKey: process.env.NEWSLETTER_API_KEY!,
documentType: 'newsletter',
blocksProjection: BLOCKS_PROJECTION,
renderToHtml: renderNewsletterToHtml,
});
export const GET = handler;// app/api/newsletters/send/route.ts
import { NextResponse } from 'next/server';
import { createNewsletterSendHandler } from 'sanity-plugin-newsletter/next';
import { projectId, dataset, apiVersion } from '~/sanity/lib/api';
import { renderNewsletterToHtml } from '~/lib/render-newsletter-to-html';
const BLOCKS_PROJECTION = `blocks[]{ ... }`;
const handler = createNewsletterSendHandler({
projectId,
dataset,
apiVersion,
apiKey: process.env.NEWSLETTER_API_KEY!,
documentType: 'newsletter',
blocksProjection: BLOCKS_PROJECTION,
renderToHtml: renderNewsletterToHtml,
});
export const POST = handler;
export async function OPTIONS() {
return new NextResponse(null, {
headers: {
'Access-Control-Allow-Origin':
process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'x-newsletter-api-key, Content-Type',
},
});
}// app/api/newsletters/audiences/route.ts
import { NextResponse } from 'next/server';
import { createNewsletterAudiencesHandler } from 'sanity-plugin-newsletter/next';
import { projectId, dataset, apiVersion } from '~/sanity/lib/api';
const handler = createNewsletterAudiencesHandler({
projectId,
dataset,
apiVersion,
apiKey: process.env.NEWSLETTER_API_KEY!,
documentType: 'newsletter',
renderToHtml: async () => '', // Not used for audiences
});
export const GET = handler;
export async function OPTIONS() {
return new NextResponse(null, {
headers: {
'Access-Control-Allow-Origin':
process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'x-newsletter-api-key, Content-Type',
},
});
}- Create a Resend account
- Add and verify your domain
- Create an API key at resend.com/api-keys
- Create an Audience (or Segment) and add contacts
- Set
RESEND_API_KEYandRESEND_FROM_EMAILin your env
The plugin uses Resend Broadcasts. When sending, it calls resend.broadcasts.create() with send: true. Include {{{RESEND_UNSUBSCRIBE_URL}}} in your email template for unsubscribe links.
| Variable | Where | Required | Description |
|---|---|---|---|
NEWSLETTER_API_KEY |
Next.js | Yes | Shared secret. Studio sends this as x-newsletter-api-key header. |
SANITY_STUDIO_NEWSLETTER_API_URL |
Studio | Yes (for preview) | Base URL of your Next.js app, e.g. http://localhost:3000 or https://yoursite.com |
SANITY_STUDIO_NEWSLETTER_API_KEY |
Studio | Yes (for send) | Same value as NEWSLETTER_API_KEY |
SANITY_API_READ_TOKEN |
Next.js | Yes | Sanity token with Viewer role (for preview fetch) |
SANITY_API_WRITE_TOKEN |
Next.js | Yes (for send) | Sanity token with Editor role (to patch sentAt, resendBroadcastId) |
RESEND_API_KEY |
Next.js | Yes (for send) | Resend API key |
RESEND_FROM_EMAIL |
Next.js | Yes (for send) | From address, e.g. newsletter@yourdomain.com |
NEXT_PUBLIC_SANITY_STUDIO_URL |
Next.js | Optional | Studio URL for CORS. Defaults to * if unset. |
The plugin registers a newsletter document type with:
- Content:
title,subject,previewText,blocks(array of your block types) - Delivery (read-only, shown after send):
sentAt,sentByUserId,sentByName,resendBroadcastId
Newsletters can only be sent once. After sending, the document is patched with the Resend broadcast ID and timestamp.
- Ensure
SANITY_API_READ_TOKENis set and has access to the dataset - Check that
documentIdis passed correctly (can bedrafts.xxxorxxx)
- Verify
NEWSLETTER_API_KEYmatchesSANITY_STUDIO_NEWSLETTER_API_KEY - Studio sends the key in the
x-newsletter-api-keyheader
- Add
OPTIONShandler to send and audiences routes (see examples above) - Set
NEXT_PUBLIC_SANITY_STUDIO_URLto your Studio origin
- The plugin uses
_updatedAtto trigger iframe reloads - Ensure your list query includes
_updatedAtif using the Newsletters tool
- Set
RESEND_API_KEYandRESEND_FROM_EMAIL - Verify your domain in Resend
MIT