notion-reference-architecture
Design and implement a production-ready Notion integration architecture with proper layering, caching, error handling, and testing strategies. Use when designing new Notion integrations, reviewing existing project structure, establishing architecture standards for Notion applications, or migrating from ad-hoc API calls to a layered architecture. Trigger: "notion architecture", "notion project structure", "notion reference architecture", "notion integration design", "notion layered architecture", "notion service pattern".
What this skill does
# Notion Reference Architecture
## Overview
Production-grade architecture for Notion integrations using `@notionhq/client`. This skill defines a four-layer architecture — client singleton, repository pattern, service layer, and caching — that scales from simple scripts to enterprise applications. It covers multi-integration setups (reader + writer tokens), event-driven processing, headless CMS patterns, and comprehensive testing strategies.
**Notion API version:** `2022-06-28` | **Rate limit:** 3 requests/second per integration | **Max page size:** 100
## Prerequisites
- Node.js 18+ with TypeScript strict mode enabled
- `@notionhq/client` v2.x installed (`npm install @notionhq/client`)
- A Notion internal integration created at https://www.notion.so/my-integrations
- `NOTION_TOKEN` environment variable set with the integration token
- Target databases/pages shared with the integration via "Add connections"
## Instructions
### Step 1: Establish the Client Singleton with Retry and Rate Limiting
The client layer wraps `@notionhq/client` in a singleton pattern with built-in retry logic. Notion's SDK handles basic retries, but you need explicit rate limiting and configurable timeouts for production use.
```
my-notion-app/
├── src/
│ ├── notion/
│ │ ├── client.ts # Singleton + retry + rate limiter
│ │ ├── types.ts # Domain types mapped from Notion properties
│ │ ├── extractors.ts # Type-safe property extraction helpers
│ │ └── errors.ts # Error classification and retry decisions
│ ├── repositories/
│ │ ├── database.repo.ts # NotionDatabaseRepo — query/create/update
│ │ └── page.repo.ts # NotionPageRepo — page CRUD + blocks
│ ├── services/
│ │ ├── notion.service.ts # NotionService — business logic orchestration
│ │ ├── sync.service.ts # Polling/webhook sync coordination
│ │ └── cms.service.ts # Headless CMS content retrieval
│ ├── cache/
│ │ └── notion-cache.ts # TTL cache between app and Notion API
│ ├── events/
│ │ ├── queue.ts # Event queue for webhook/polling events
│ │ └── processors.ts # Event handlers (page.created, page.updated)
│ └── index.ts
├── tests/
│ ├── unit/
│ │ ├── extractors.test.ts
│ │ ├── database.repo.test.ts
│ │ └── notion.service.test.ts
│ └── integration/
│ └── notion-live.test.ts
├── .env.example
└── tsconfig.json
```
Create the client singleton with rate limiting:
```typescript
// src/notion/client.ts
import { Client, LogLevel } from '@notionhq/client';
let readerClient: Client | null = null;
let writerClient: Client | null = null;
interface ClientOptions {
token: string;
logLevel?: LogLevel;
timeoutMs?: number;
}
function createClient(opts: ClientOptions): Client {
return new Client({
auth: opts.token,
logLevel: opts.logLevel ?? (process.env.NODE_ENV === 'development'
? LogLevel.DEBUG : LogLevel.WARN),
timeoutMs: opts.timeoutMs ?? 30_000,
});
}
// Primary client — read-heavy operations
export function getReaderClient(): Client {
if (!readerClient) {
const token = process.env.NOTION_READER_TOKEN ?? process.env.NOTION_TOKEN;
if (!token) throw new Error('NOTION_TOKEN or NOTION_READER_TOKEN required');
readerClient = createClient({ token });
}
return readerClient;
}
// Writer client — separate integration with write permissions
export function getWriterClient(): Client {
if (!writerClient) {
const token = process.env.NOTION_WRITER_TOKEN ?? process.env.NOTION_TOKEN;
if (!token) throw new Error('NOTION_TOKEN or NOTION_WRITER_TOKEN required');
writerClient = createClient({ token });
}
return writerClient;
}
// Simple rate limiter: 3 req/s per integration (Notion's limit)
const requestTimestamps: number[] = [];
const MAX_REQUESTS_PER_SECOND = 3;
export async function rateLimitedCall<T>(fn: () => Promise<T>): Promise<T> {
const now = Date.now();
// Remove timestamps older than 1 second
while (requestTimestamps.length > 0 && requestTimestamps[0] < now - 1000) {
requestTimestamps.shift();
}
if (requestTimestamps.length >= MAX_REQUESTS_PER_SECOND) {
const waitMs = 1000 - (now - requestTimestamps[0]);
await new Promise(resolve => setTimeout(resolve, waitMs));
}
requestTimestamps.push(Date.now());
return fn();
}
// Retry wrapper with exponential backoff
export async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelayMs = 500,
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await rateLimitedCall(fn);
} catch (error: any) {
const isRetryable = error?.code === 'rate_limited'
|| error?.code === 'internal_server_error'
|| error?.code === 'service_unavailable';
if (!isRetryable || attempt === maxRetries) throw error;
const delay = baseDelayMs * Math.pow(2, attempt);
const retryAfter = error?.headers?.['retry-after'];
const waitMs = retryAfter ? parseInt(retryAfter) * 1000 : delay;
await new Promise(resolve => setTimeout(resolve, waitMs));
}
}
throw new Error('Unreachable');
}
// For testing — inject mock clients
export function _setClients(reader: Client | null, writer?: Client | null) {
readerClient = reader;
writerClient = writer ?? reader;
}
```
### Step 2: Build the Repository and Service Layers
The repository layer wraps raw Notion API calls with pagination, type extraction, and error handling. The service layer sits above it with business logic, caching, and cross-repository coordination.
**Repository pattern — NotionDatabaseRepo:**
```typescript
// src/repositories/database.repo.ts
import { getReaderClient, getWriterClient, withRetry } from '../notion/client';
import { extractTitle, extractSelect, extractRichText, extractDate }
from '../notion/extractors';
import type { PageObjectResponse, QueryDatabaseParameters }
from '@notionhq/client/build/src/api-endpoints';
export interface DatabaseRecord {
id: string;
title: string;
status: string | null;
description: string;
dueDate: { start: string; end: string | null } | null;
url: string;
lastEdited: string;
}
export class NotionDatabaseRepo {
// Paginate through all results (Notion caps at 100 per request)
async queryAll(
databaseId: string,
filter?: QueryDatabaseParameters['filter'],
sorts?: QueryDatabaseParameters['sorts'],
): Promise<PageObjectResponse[]> {
const reader = getReaderClient();
const pages: PageObjectResponse[] = [];
let cursor: string | undefined;
do {
const response = await withRetry(() =>
reader.databases.query({
database_id: databaseId,
filter,
sorts: sorts ?? [{ timestamp: 'last_edited_time', direction: 'descending' }],
page_size: 100,
start_cursor: cursor,
})
);
for (const result of response.results) {
if ('properties' in result) {
pages.push(result as PageObjectResponse);
}
}
cursor = response.has_more ? response.next_cursor ?? undefined : undefined;
} while (cursor);
return pages;
}
// Map raw Notion pages to typed domain objects
async getRecords(databaseId: string, statusFilter?: string): Promise<DatabaseRecord[]> {
const filter = statusFilter
? { property: 'Status', select: { equals: statusFilter } }
: undefined;
const pages = await this.queryAll(databaseId, filter);
return pages.map(page => ({
id: page.id,
title: extractTitle(page, 'Name'),
status: extractSelect(page, 'Status'),
description: extractRichText(page, 'Description'),
dueDate: extractDate(page, 'Due Date'),
url: page.url,
lastEdited: page.last_edited_time,
}));
}
// Create a new page in the database
async create(
databaseId: string,
properties: Record<string, any>,
): Promise<string> {
const writer = getWriterClient(Related in Ads & Marketing
ads
IncludedMulti-platform paid advertising audit and optimization skill. Analyzes Google, Meta, YouTube, LinkedIn, TikTok, Microsoft, and Apple Ads. 250+ checks with scoring, parallel agents, industry templates, and AI creative generation.
banana
IncludedAI image generation Creative Director powered by Google Gemini Nano Banana models. Use this skill for ANY request involving image creation, editing, visual asset production, or creative direction. Triggers on: generate an image, create a photo, edit this picture, design a logo, make a banner, visual for my anything, and all /banana commands. Handles text-to-image, image editing, multi-turn creative sessions, batch workflows, and brand presets.
rpg-migration-analyzer
IncludedAnalyzes legacy RPG (Report Program Generator) programs from AS/400 and IBM i systems for migration to modern Java applications. Extracts business logic from RPG III/IV/ILE source code, identifies data structures (D-specs), file operations (F-specs), program dependencies (CALLB/CALLP), and converts RPG constructs to Java equivalents. Generates migration reports, complexity estimates, and Java implementation strategies with POJO classes, JPA entities, and service methods. Use when modernizing AS/400 or IBM i legacy systems, analyzing RPG source files (.rpg, .rpgle, .RPGLE), converting RPG to Java, mapping data specifications to Java classes, planning legacy system migration, or when user mentions RPG analysis, Report Program Generator, RPG III/IV/ILE, AS/400 modernization, IBM i migration, packed decimal conversion, or mainframe application rewrite.
brand-library-architect
IncludedBuild a complete brand library for a product — visual asset render pipeline, brand documentation set (BRAND, COPY, MANIFESTO, BIOS, FAQ, GLOSSARY, TONE, PRICING), open-source convention files (README, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT), and a self-contained press kit. This skill should be used when the user asks to "build a brand library / brand kit / press kit / brand assets" for a product, "set up a brand library workflow," "create a positioning manifesto plus visual identity," or any combination of brand documentation + visual asset pipeline. Apply phase-by-phase or run end-to-end. Templates are product-agnostic and use {{TOKEN}} placeholders the skill prompts the user to fill.
writing-tech-post
IncludedAuthors engineering blog posts end-to-end: launch deep-dives, incident postmortems, architecture migrations, performance case studies, tutorials, AI/agent system writeups, security disclosures, and research-to-product translations. Picks the correct archetype, plans the abstraction ladder, enforces an evidence cadence (diagrams, benchmarks, profiles, traces, code, ablations), tunes voice against publisher house styles (Datadog, Vercel, GitHub, AWS, Meta, Cloudflare, Jane Street), and runs a pre-publish gate for narrative momentum and disclosure ethics. Use when drafting a new engineering post, restructuring a draft that feels flat, deciding which evidence form belongs where, validating that depth and product context are balanced, or preparing a postmortem, migration, or performance narrative for external publication. Do not use for API reference documentation, README authoring, marketing copy, release notes, generic SEO content, ghost-written executive thought leadership, or non-engineering long-form essays.
blog-google
IncludedGoogle API integration for blog performance: PageSpeed Insights, CrUX Core Web Vitals with 25-week history, Search Console performance, URL Inspection, Indexing API, GA4 organic traffic, NLP entity analysis for E-E-A-T, YouTube video search for embedding, and Google Ads Keyword Planner. Progressive feature availability based on credential tier (API key, OAuth/service account, GA4, Ads). Shares config with claude-seo at ~/.config/claude-seo/google-api.json. Use when user says "google data", "page speed", "core web vitals", "search console", "indexation", "GA4", "keyword research", "nlp entities", "blog performance", "youtube search", "google api setup".