notion-upstash-cms
v0.4.0
Published
Sync Notion data sources to Upstash Redis with webhook-based real-time updates
Maintainers
Readme
notion-upstash-cms
Sync Notion data sources to Upstash Redis with webhook-based real-time updates. Keep your Notion CMS in sync with your application's cache automatically.
Features
- 🔄 Real-time sync via Notion webhooks
- 📦 Option B storage - one key per page + indexes for fast queries
- 🎯 Config-based - define your field mappings once
- 🔌 Framework agnostic - works with Next.js, Express, or any Node.js runtime
- 📝 Markdown support - automatic content extraction from Notion blocks
- 🚀 Edge-ready - works in Edge runtimes (Vercel Edge, Cloudflare Workers)
- 🔒 Type-safe - full TypeScript support
Installation
npm install notion-upstash-cms
# or
pnpm add notion-upstash-cms
# or
yarn add notion-upstash-cmsQuick Start
1. Create a config file
Create notion-upstash.config.ts:
import { defineConfig } from 'notion-upstash-cms';
export default defineConfig({
dataSources: [
{
id: 'your-data-source-id',
name: 'blog',
content: {
mode: 'markdown',
field: 'content',
},
storage: {
primaryIndex: { field: 'publishedAt', type: 'timestamp' },
},
fields: {
id: { type: 'meta', source: 'page.id' },
title: { type: 'title' },
slug: {
type: 'rich_text',
propertyId: 'PBBh',
transforms: ['slugify'],
},
excerpt: { type: 'rich_text', propertyId: '%7B%40V%3C' },
publishedAt: { type: 'date', propertyId: 'kdM%7C' },
tags: {
type: 'multi_select',
propertyId: 'BzMr',
value: 'name',
},
featured: {
type: 'checkbox',
propertyId: 'Izfp',
default: false,
},
image: { type: 'url', propertyId: '%3D_%5E%5C' },
lastEdited: { type: 'meta', source: 'page.last_edited_time' },
created: { type: 'meta', source: 'page.created_time' },
},
},
],
});2. Set up webhook handler
Next.js (Edge Runtime)
// app/api/notion-webhook/route.ts
import rawConfig from '../../../notion-upstash.config';
import { withEnv } from 'notion-upstash-cms';
import { createNextRouteHandler } from 'notion-upstash-cms/next';
const config = withEnv(rawConfig, {
notionToken: process.env.NOTION_TOKEN!,
redis: {
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
prefix: 'notioncms',
},
});
export const runtime = 'edge';
const routeHandler = createNextRouteHandler(config);
export const POST = routeHandler;Express
import express from 'express';
import rawConfig from './notion-upstash.config';
import { withEnv } from 'notion-upstash-cms';
import { createExpressMiddleware } from 'notion-upstash-cms/express';
const config = withEnv(rawConfig, {
notionToken: process.env.NOTION_TOKEN!,
redis: {
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
prefix: 'notioncms',
},
});
const app = express();
app.use(express.text({ type: 'application/json' }));
app.post('/notion-webhook', createExpressMiddleware(config));3. Read data in your app
import { createReader, withEnv } from 'notion-upstash-cms';
import rawConfig from './notion-upstash.config';
const config = withEnv(rawConfig, {
notionToken: process.env.NOTION_TOKEN!,
redis: {
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
},
});
const reader = createReader(config);
// Get all blog posts
const posts = await reader.getAll('blog');
// Get post by slug
const post = await reader.getBySlug('blog', 'my-post-slug');
// Get post by ID
const postById = await reader.getById('blog', 'page-id');
// List posts by index (e.g., publishedAt)
const recentPosts = await reader.listByIndex('blog', 'publishedAt', {
limit: 10,
order: 'desc',
});4. Manual sync (optional)
import { syncDataSource, withEnv } from 'notion-upstash-cms';
import rawConfig from './notion-upstash.config';
const config = withEnv(rawConfig, {
notionToken: process.env.NOTION_TOKEN!,
redis: {
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
},
});
// Sync all pages from 'blog' data source
await syncDataSource('blog', config);Configuration
Field Types
meta- Page metadata (page.id,page.last_edited_time,page.created_time)title- Title propertyrich_text- Rich text propertydate- Date propertycheckbox- Checkbox propertymulti_select- Multi-select propertyselect- Select propertyurl- URL propertyemail- Email propertyphone- Phone propertynumber- Number property
Transforms
Built-in transforms:
slugify- Convert text to URL-safe slugoptimizeSeoTitle- Truncate title at word boundarytruncate(length)- Truncate text to specified length
Example:
slug: {
type: 'rich_text',
propertyId: 'PBBh',
transforms: ['slugify'],
}Lifecycle Hooks
Optional hooks for advanced use cases:
{
name: 'blog',
// ... fields
hooks: {
beforeUpsert: async (page, transformed) => {
// Modify transformed data before storing
return transformed;
},
afterUpsert: async (page, transformed) => {
// Side effects after storing (e.g., invalidate cache)
},
beforeDelete: async (pageId) => {
// Side effects before deleting
},
},
}Storage Strategy (Option B)
This package uses Option B storage strategy:
- One key per page:
notioncms:{name}:page:{id} - Indexes: Sorted sets for fast queries
notioncms:{name}:index:all- All pages indexed by created timenotioncms:{name}:index:{field}- Custom index (ifprimaryIndexconfigured)
This provides:
- Fast individual page lookups
- Efficient pagination and sorting
- Better scalability than single JSON blob
API Reference
defineConfig(config)
Defines a static configuration (no secrets). Returns the config with TypeScript autocomplete.
withEnv(staticConfig, env)
Combines static config with runtime secrets.
Parameters:
staticConfig- Static config fromdefineConfigenv- Runtime environment:notionToken- Notion API tokenredis.url- Upstash Redis REST API URLredis.token- Upstash Redis REST API tokenredis.prefix- Optional Redis key prefix (default:'notioncms')notionVersion- Optional Notion API version (default:'2025-09-03')
createNotionWebhookHandler(config)
Creates a webhook handler function.
Returns: (rawBody: string | object) => Promise<void>
createNextRouteHandler(config)
Creates a Next.js route handler.
Returns: (req: Request) => Promise<Response>
createExpressMiddleware(config)
Creates an Express middleware.
Returns: Express middleware function
syncDataSource(dsNameOrId, config)
Manually syncs a data source from Notion.
Parameters:
dsNameOrId- Data source name or IDconfig- Complete configuration
createReader(config)
Creates a reader instance for querying cached data.
Returns: Reader object with methods:
getAll(dsName)- Get all pagesgetById(dsName, id)- Get page by IDgetBySlug(dsName, slug)- Get page by sluglistByIndex(dsName, indexField, options)- List pages by index
Notion Setup
- Create a Notion integration at https://www.notion.so/my-integrations
- Get your integration token
- Share your database/data source with the integration
- Get your data source ID from the database URL or API
- Set up a webhook in Notion pointing to your webhook endpoint
Environment Variables
NOTION_TOKEN=secret_xxx
KV_REST_API_URL=https://xxx.upstash.io
KV_REST_API_TOKEN=xxxMigration from Option A
If you're migrating from single-key storage (Option A):
- Your old data was stored as:
blog-database→{ posts: [...], lastUpdated: "..." } - New storage uses:
notioncms:blog:page:{id}→{ ...page data } - Run a full sync to migrate:
await syncDataSource('blog', config) - Update your read code to use
createReaderinstead of direct Redis access
Examples
See the examples/ directory for complete examples:
nextjs-edge/- Next.js Edge runtime exampleexpress/- Express.js examplebasic/- Minimal setup example
Contributing
Contributions are welcome! Please see CONTRIBUTING.md for guidelines.
License
MIT
Troubleshooting
Webhook not receiving events
- Verify your webhook URL is accessible
- Check Notion integration has access to the database
- Ensure webhook is configured in Notion settings
Pages not syncing
- Check data source ID matches your Notion data source
- Verify property IDs are correct (use inspect helper if available)
- Check webhook logs for errors
Property not found
- Ensure property ID is URL-encoded correctly
- Fallback to
propertyNameif property ID is unavailable - Verify property type matches field mapping type
