Claude
Skills
Sign in
Back

notion-local-dev-loop

Included with Lifetime
$97 forever

Configure Notion local development with a dedicated dev integration, test mocking, and hot reload. Use when setting up a development environment, writing tests for Notion code, or establishing a fast iteration cycle with the Notion API. Trigger: "notion dev setup", "notion local development", "mock notion", "notion test environment".

Backend & APIssaasproductivitynotion

What this skill does

# Notion Local Dev Loop

## Overview

Set up a fast, reproducible local development workflow for Notion integrations. This skill covers creating a dedicated dev integration with its own token, structuring the project for testability, mocking the Notion SDK in unit tests, and running integration tests against a sandboxed dev workspace. The approach keeps production data safe while enabling rapid iteration.

## Prerequisites

- Completed `notion-install-auth` setup (you have a working Notion integration)
- Node.js 18+ with npm/pnpm, or Python 3.10+
- A Notion workspace where you can create test pages and databases

## Instructions

### Step 1: Create a Dev Integration and Workspace Sandbox

Create a separate integration exclusively for development. This prevents accidental writes to production data.

1. Go to **Settings & Members > Connections > Develop or manage integrations** (or visit [developers.notion.com](https://developers.notion.com))
2. Click **New integration** and name it `My App — Dev`
3. Copy the token (starts with `ntn_`) into `.env.development`
4. Create a dedicated **Dev Workspace** page (or a top-level "Dev Testing" page) and share it with the dev integration
5. Inside that page, create test databases that mirror your production schema

```bash
# .env.development — git-ignored, dev only
NOTION_TOKEN=ntn_dev_xxxxxxxxxxxxxxxxxxxx
NOTION_TEST_DATABASE_ID=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
NOTION_TEST_PAGE_ID=ffffffff-0000-1111-2222-333333333333

# .env.example — commit this as a template
NOTION_TOKEN=ntn_your_dev_token_here
NOTION_TEST_DATABASE_ID=your_test_db_id
NOTION_TEST_PAGE_ID=your_test_page_id
```

Project structure:

```
my-notion-project/
├── src/
│   ├── notion/
│   │   ├── client.ts          # Singleton with retry + rate-limit awareness
│   │   ├── queries.ts         # Database query wrappers
│   │   └── helpers.ts         # Property extractors, rich text builders
│   └── index.ts
├── tests/
│   ├── unit/
│   │   └── notion.test.ts     # Mocked SDK tests
│   └── integration/
│       └── notion.test.ts     # Live API tests (gated)
├── .env.development            # Dev token (git-ignored)
├── .env.example                # Template for team
├── .gitignore
├── package.json
├── tsconfig.json
└── vitest.config.ts
```

### Step 2: Configure the Client with Retry and Rate-Limit Handling

The Notion API enforces a hard limit of **3 requests per second** across all pricing tiers. Build retry logic into your client from day one.

```typescript
// src/notion/client.ts
import { Client, LogLevel, isNotionClientError, APIResponseError } from '@notionhq/client';

let instance: Client | null = null;

export function getNotionClient(): Client {
  if (!instance) {
    instance = new Client({
      auth: process.env.NOTION_TOKEN,   // SDK reads NOTION_TOKEN automatically if omitted
      logLevel: process.env.NODE_ENV === 'development' ? LogLevel.DEBUG : LogLevel.WARN,
      // baseUrl can be overridden for proxy/mock servers:
      // baseUrl: process.env.NOTION_BASE_URL || 'https://api.notion.com',
    });
  }
  return instance;
}

// Retry wrapper with exponential backoff for rate limits
export async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (
        isNotionClientError(error) &&
        error instanceof APIResponseError &&
        error.status === 429 &&
        attempt < maxRetries
      ) {
        const retryAfter = parseInt(error.headers?.get('retry-after') || '1', 10);
        const delay = retryAfter * 1000 * Math.pow(2, attempt);
        console.warn(`Rate limited. Retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
        await new Promise(resolve => setTimeout(resolve, delay));
        continue;
      }
      throw error;
    }
  }
  throw new Error('Unreachable');
}
```

```json
{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "dev:debug": "NOTION_LOG_LEVEL=debug tsx watch src/index.ts",
    "test": "vitest",
    "test:watch": "vitest --watch",
    "test:integration": "INTEGRATION=true vitest run tests/integration/",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@notionhq/client": "^2.2.0"
  },
  "devDependencies": {
    "tsx": "^4.0.0",
    "typescript": "^5.0.0",
    "vitest": "^2.0.0",
    "dotenv": "^16.0.0"
  }
}
```

### Step 3: Write Unit Tests with Mocked SDK and Integration Tests

**Unit tests** mock the entire `@notionhq/client` module so they run instantly with no network calls. **Integration tests** hit the real API but are gated behind an environment variable and target only the dev workspace.

```typescript
// tests/unit/notion.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Client } from '@notionhq/client';

vi.mock('@notionhq/client', () => ({
  Client: vi.fn().mockImplementation(() => ({
    databases: {
      query: vi.fn(),
      retrieve: vi.fn(),
      create: vi.fn(),
      update: vi.fn(),
    },
    pages: {
      create: vi.fn(),
      update: vi.fn(),
      retrieve: vi.fn(),
    },
    blocks: {
      children: { list: vi.fn(), append: vi.fn() },
      retrieve: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    },
    search: vi.fn(),
    users: { list: vi.fn(), retrieve: vi.fn() },
  })),
  isNotionClientError: vi.fn((err) => err?.code !== undefined),
  LogLevel: { DEBUG: 'debug', WARN: 'warn' },
}));

describe('Database queries', () => {
  let notion: InstanceType<typeof Client>;

  beforeEach(() => {
    notion = new Client({ auth: 'ntn_test_token' });
  });

  it('queries database with a status filter', async () => {
    const mockResponse = {
      results: [
        {
          id: 'page-1',
          properties: {
            Name: { type: 'title', title: [{ plain_text: 'Task 1' }] },
            Status: { type: 'select', select: { name: 'Done' } },
          },
        },
      ],
      has_more: false,
      next_cursor: null,
    };
    (notion.databases.query as ReturnType<typeof vi.fn>).mockResolvedValue(mockResponse);

    const result = await notion.databases.query({
      database_id: 'test-db-id',
      filter: { property: 'Status', select: { equals: 'Done' } },
    });

    expect(result.results).toHaveLength(1);
    expect(notion.databases.query).toHaveBeenCalledWith(
      expect.objectContaining({
        filter: { property: 'Status', select: { equals: 'Done' } },
      })
    );
  });

  it('handles pagination across multiple pages', async () => {
    const queryMock = notion.databases.query as ReturnType<typeof vi.fn>;
    queryMock
      .mockResolvedValueOnce({ results: [{ id: '1' }], has_more: true, next_cursor: 'cursor-abc' })
      .mockResolvedValueOnce({ results: [{ id: '2' }], has_more: false, next_cursor: null });

    const page1 = await notion.databases.query({ database_id: 'db' });
    expect(page1.has_more).toBe(true);

    const page2 = await notion.databases.query({
      database_id: 'db',
      start_cursor: page1.next_cursor,
    });
    expect(page2.has_more).toBe(false);
    expect(queryMock).toHaveBeenCalledTimes(2);
  });
});
```

```typescript
// tests/integration/notion.test.ts
import { describe, it, expect } from 'vitest';
import { Client } from '@notionhq/client';

const SKIP = !process.env.INTEGRATION;

describe.skipIf(SKIP)('Notion Integration (live API)', () => {
  const notion = new Client({ auth: process.env.NOTION_TOKEN! });
  const testDbId = process.env.NOTION_TEST_DATABASE_ID!;

  it('connects and lists workspace users', async () => {
    const { results } = await notion.users.list({});
    expect(results.length).toBeGreaterThan(0);
  });

  it('queries the test database', async () => {
    const response = await notion.databases.query({
      database_id: testDbId,
      page_size: 1,
    });
    expect(response.results).toBeDefined();
  });

  it('creates and archives a test page (cleanup)', async () => {

Related in Backend & APIs