| name | notion-api |
| description | [Applies to: **/*] Definitive guidelines for building secure, typed, and maintainable integrations with the Notion API using modern best practices and official SDKs. |
| source | cursor_mdc |
notion-api Best Practices
This guide establishes the definitive best practices for interacting with the Notion API. Adhering to these rules ensures our integrations are secure, performant, type-safe, and easily maintainable. We prioritize official SDKs and structured data handling.
1. Code Organization and Structure
Organize your Notion API interactions into dedicated modules. Separate concerns like authentication, data fetching, and data transformation.
✅ GOOD: Modular Structure
import { Client } from '@notionhq/client';
export const notionClient = new Client({
auth: process.env.NOTION_API_TOKEN,
});
import { notionClient } from './client';
import { CreatePageParameters, GetPageResponse } from '@notionhq/client/build/src/api-endpoints';
export async function createNotionPage(params: CreatePageParameters): Promise<GetPageResponse> {
return notionClient.pages.create(params);
}
import { notionClient } from './client';
import { QueryDatabaseParameters, QueryDatabaseResponse } from '@notionhq/client/build/src/api-endpoints';
export async function queryNotionDatabase(databaseId: string, params?: QueryDatabaseParameters): Promise<QueryDatabaseResponse> {
return notionClient.databases.query({ database_id: databaseId, ...params });
}
❌ BAD: Monolithic or Scattered Logic
import { Client } from '@notionhq/client';
async function main() {
const notion = new Client({ auth: 'secret_token_hardcoded' });
const response = await notion.databases.query({ database_id: 'some_id' });
}
2. Authentication and Token Management
Always use granular integration tokens, store them in environment variables, and implement rotation. Never hardcode tokens.
✅ GOOD: Secure Token Handling
NOTION_API_TOKEN="secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
import { Client } from '@notionhq/client';
if (!process.env.NOTION_API_TOKEN) {
throw new Error('NOTION_API_TOKEN is not set in environment variables.');
}
export const notionClient = new Client({
auth: process.env.NOTION_API_TOKEN,
});
❌ BAD: Insecure Token Handling
const notion = new Client({ auth: 'secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' });
3. Typed SDKs and Data Structures
Leverage the official Notion SDKs (e.g., @notionhq/client for TypeScript/JavaScript, notion-sdk for Python) for type safety and schema validation. Prefer page properties for structured data and blocks for free-form content.
✅ GOOD: Using Typed SDK for Page Creation
import { notionClient } from './client';
import { CreatePageParameters } from '@notionhq/client/build/src/api-endpoints';
async function createProjectPage(databaseId: string, projectName: string, dueDate: string) {
const params: CreatePageParameters = {
parent: { database_id: databaseId },
properties: {
'Name': {
title: [{ text: { content: projectName } }],
},
'Due Date': {
date: { start: dueDate },
},
'Status': {
select: { name: 'To Do' },
},
},
children: [
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [{ type: 'text', text: { content: 'Initial project description.' } }],
},
},
],
};
return notionClient.pages.create(params);
}
❌ BAD: Manual JSON Construction & Mixing Concerns
async function createUntypedPage(databaseId: string, projectName: string) {
return fetch('https://api.notion.com/v1/pages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.NOTION_API_TOKEN}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({
parent: { database_id: databaseId },
properties: {
'Name': {
title: [{ text: { content: projectName } }],
},
'Description': {
rich_text: [{ text: { content: 'This is a description.' } }],
},
},
}),
});
}
4. Performance Considerations
Optimize API calls by fetching only necessary data, using pagination, and batching operations where supported.
✅ GOOD: Paginated Database Query with Filters
import { notionClient } from './client';
import { QueryDatabaseResponse } from '@notionhq/client/build/src/api-endpoints';
async function getAllActiveTasks(databaseId: string): Promise<QueryDatabaseResponse['results']> {
let allResults: QueryDatabaseResponse['results'] = [];
let cursor: string | undefined = undefined;
while (true) {
const response = await notionClient.databases.query({
database_id: databaseId,
filter: {
property: 'Status',
select: {
does_not_equal: 'Done',
},
},
page_size: 100,
start_cursor: cursor,
});
allResults = allResults.concat(response.results);
if (!response.has_more) {
break;
}
cursor = response.next_cursor || undefined;
}
allResults;
}
❌ BAD: Fetching All Data Without Pagination
async function getTooManyTasks(databaseId: string) {
return notionClient.databases.query({
database_id: databaseId,
});
}
5. Error Handling and Rate Limiting
Implement robust error handling, including retries with exponential back-off for rate limits and transient errors. Always check X-RateLimit-Remaining.
✅ GOOD: Robust Error Handling with Exponential Back-off
import { notionClient } from './client';
import { APIResponseError } from '@notionhq/client';
async function safeNotionCall<T>(
fn: () => Promise<T>,
retries = 3,
delay = 1000
): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof APIResponseError) {
if (error.status === 429 && retries > 0) {
console.warn(`Rate limit hit. Retrying in ${delay / 1000}s...`);
await new Promise(resolve => setTimeout(resolve, delay));
return safeNotionCall(fn, retries - 1, delay * 2);
}
console.error(`Notion API Error (${error.status}): ${error.message}`);
throw error;
}
.(, error);
error;
}
}
() {
(
notionClient..({ : pageId, properties })
);
}
❌ BAD: Ignoring Errors or Blind Retries
async function unsafeNotionCall(pageId: string, properties: any) {
try {
await notionClient.pages.update({ page_id: pageId, properties });
} catch (error) {
console.error('Failed to update page:', error);
}
}
6. Request/Response Patterns
Understand and leverage the object and type fields in Notion API responses for dynamic content handling. Always assume nested blocks and rich text.
✅ GOOD: Processing Block Children
import { notionClient } from './client';
import { BlockObjectResponse } from '@notionhq/client/build/src/api-endpoints';
async function processBlockChildren(blockId: string) {
const { results } = await notionClient.blocks.children.list({ block_id: blockId });
for (const block of results) {
if ('type' in block) {
console.log(`Block Type: ${block.type}`);
if (block.type === 'paragraph' && block.paragraph.rich_text) {
console.log('Paragraph content:', block.paragraph.rich_text.map(rt => rt.plain_text).join(''));
}
if (block.has_children) {
console.log();
(block.);
}
}
}
}
❌ BAD: Assuming Flat Structure or Ignoring has_children
async function incompleteBlockProcessing(blockId: string) {
const { results } = await notionClient.blocks.children.list({ block_id: blockId });
for (const block of results) {
if ('type' in block && block.type === 'paragraph') {
console.log(block.paragraph.rich_text[0]?.plain_text);
}
}
}
7. Testing Approaches
Implement unit tests for utility functions and integration tests for API interactions. Use mock clients for unit tests and a dedicated, isolated Notion workspace for integration tests.
✅ GOOD: Mocking Notion Client for Unit Tests
export function extractPageTitle(page: any): string {
const titleProperty = page.properties.Name?.title;
return titleProperty ? titleProperty.map((t: any) => t.plain_text).join('') : 'Untitled';
}
import { extractPageTitle } from '../src/utils/notionHelpers';
describe('extractPageTitle', () => {
it('should extract the title from a Notion page object', () => {
const mockPage = {
properties: {
Name: {
title: [{ type: 'text', text: { content: 'My Test Page' }, plain_text: 'My Test Page' }],
},
},
};
expect(extractPageTitle(mockPage)).toBe('My Test Page');
});
it('should return "Untitled" if title property is missing', () => {
mockPage = { : {} };
((mockPage)).();
});
});
✅ GOOD: Integration Tests with Dedicated Workspace
- Set up a separate Notion workspace or database specifically for testing.
- Use a dedicated integration token with minimal permissions for this test workspace.
- Clean up test data after each test run.
import { notionClient } from '../../src/notion/client';
import { v4 as uuidv4 } from 'uuid';
const TEST_DATABASE_ID = process.env.NOTION_TEST_DATABASE_ID!;
describe('Notion API Integration', () => {
let createdPageId: string;
beforeAll(() => {
if (!TEST_DATABASE_ID) {
throw new Error('NOTION_TEST_DATABASE_ID must be set for integration tests.');
}
});
afterEach(async () => {
if (createdPageId) {
await notionClient.pages.update({
page_id: createdPageId,
archived: true,
});
createdPageId = '';
}
});
it('should create and retrieve a page in the test database', async () => {
const pageTitle = `Test Page `;
newPage = notionClient..({
: { : },
: {
: {
: [{ : { : pageTitle } }],
},
},
});
createdPageId = newPage.;
(newPage...).();
retrievedPage = notionClient..({ : createdPageId });
((retrievedPage)).(pageTitle);
});
});
❌ BAD: No Testing or Manual Testing Only
- Relying solely on manual checks after deployment.
- No automated verification of API interactions.