Skip to main content

Overview

This website uses Notion databases as a headless CMS, leveraging the official Notion JavaScript SDK to fetch and transform content. The integration is built around a custom set of utilities in src/lib/notion-*.ts that handle API communication, content parsing, and asset management.

Notion API Client

The API client is implemented as a singleton to ensure consistent configuration across the application.
import { Client } from '@notionhq/client';

const notion = new Client({
  auth: import.meta.env.NOTION_TOKEN,
});

export default notion;
The singleton pattern prevents multiple client instances and ensures the API token is read once from environment variables.

Database Querying

Data Source Resolution

Notation’s 2025 API update introduced data sources. The integration automatically resolves database IDs to data source IDs with caching:
// src/lib/notion-cms.ts:27-63
const dataSourceIdCache = new Map<string, string>();

export async function getDataSourceId(databaseId: string): Promise<string> {
  // Check cache first
  if (dataSourceIdCache.has(databaseId)) {
    return dataSourceIdCache.get(databaseId)!;
  }

  const response = await notion.databases.retrieve({ database_id: databaseId });
  const database = ensureFullResponse<DatabaseObjectResponse, PartialDatabaseObjectResponse>(response);

  if (!database.data_sources || database.data_sources.length === 0) {
    throw new Error(`No data sources found for database: ${databaseId}`);
  }

  const dataSourceId = database.data_sources[0].id;
  
  // Cache for subsequent queries
  dataSourceIdCache.set(databaseId, dataSourceId);
  
  return dataSourceId;
}
The cache persists for the duration of the build process, avoiding redundant API calls when querying the same database multiple times.

Query Helper Function

The queryNotionDatabase function provides a high-level interface with automatic pagination:
// src/lib/notion-cms.ts:73-111
export async function queryNotionDatabase(
  databaseId: string,
  options?: Omit<QueryDataSourceParameters, 'data_source_id'>,
): Promise<PageObjectResponse[]> {
  const dataSourceId = await getDataSourceId(databaseId);

  const pages: PageObjectResponse[] = [];
  let cursor = undefined;
  
  while (true) {
    const { results, next_cursor } = await notion.dataSources.query({
      ...options,
      data_source_id: dataSourceId,
      start_cursor: cursor,
    });

    for (const result of results) {
      if ('object' in result && result.object === 'page') {
        const page = ensureFullResponse<PageObjectResponse, PartialPageObjectResponse>(result);
        pages.push(page);
      }
    }

    if (!next_cursor) break;
    cursor = next_cursor;
  }

  return pages;
}
Always use ensureFullResponse() to convert partial responses to full responses. Partial responses lack critical fields needed for parsing.

Example: Fetching Blog Posts

import { queryNotionDatabase } from './notion-cms';

const posts = await queryNotionDatabase(import.meta.env.NOTION_BLOG_DB_ID, {
  filter: {
    and: [
      {
        property: 'public',
        checkbox: { equals: true },
      },
    ],
  },
  sorts: [
    {
      property: 'published',
      direction: 'descending',
    },
  ],
});

Database Schema

Both blog and project databases follow a consistent schema defined in src/content.config.ts:
lastEditedTime
Date
required
Notion’s last modification timestamp, used for incremental sync
published
string
required
Publication date in YYYY-MM-DD format
description
string
required
SEO meta description for the post/project
path
string
required
URL slug (e.g., “my-blog-post”)
tags
string
Comma-separated tags for categorization
public
boolean
required
Visibility toggle - only public items are synced
title
string
required
Page title
dates
string
Optional date range (projects only)

Block Fetching

The getBlock function recursively fetches all child blocks and downloads associated assets:
// src/lib/notion-cms.ts:143-181
export async function getBlock(blockId: string) {
  let content = await getBlockChildren(blockId);

  return Promise.all(
    content.map(async (blockResponse) => {
      const block = ensureFullResponse<BlockObjectResponse, PartialBlockObjectResponse>(blockResponse);

      const blockType = block.type;
      
      // Download supported media types
      if (
        blockType === "image" ||
        blockType === "video" ||
        blockType === "audio" ||
        blockType === "pdf"
      ) {
        if (block[blockType].type === "file") {
          // Download asset and remap URL to local path
          block[blockType].file.url = await getAssetUrl(
            block.id,
            block[blockType].file.url,
            blockType === "image",
          );
        }
      }

      // Recursively fetch children
      if (block.has_children) {
        (block as any).children = await getBlockChildren(block.id);
      }

      return block;
    }),
  );
}
The recursive structure ensures nested content (like toggle lists or indented bullets) is fully captured.

Page Properties

The getPageProperties function extracts metadata from Notion pages:
// src/lib/notion-cms-page.ts:9-25
export async function getPageProperties(pageId: string): Promise<any> {
  const response = await notion.pages.retrieve({ page_id: pageId });
  const fullResponse = ensureFullResponse<PageObjectResponse, PartialPageObjectResponse>(response);

  const properties = fullResponse.properties;
  const result = {};

  Object.keys(properties).forEach((key) => {
    const property = properties[key];
    result[key] = parseProperty(property);  // Converts to plain strings
  });

  return result;
}
This is used to generate MDX frontmatter (see src/lib/notion-download.ts:11-24).

Asset Management

Notion assets are downloaded locally to avoid expiring URLs and improve performance.

Download Strategy

// src/lib/notion-cms-asset.ts:16-58
export async function getAssetUrl(
  blockId: string,
  url: string,
  isImage: boolean = false
): Promise<string> {
  const downloadPath = isImage ? ASSET_SRC_PATH : ASSET_PUBLIC_PATH;

  const files = await fs.promises.readdir(downloadPath);
  let filename = files.find((file) => file.includes(blockId));

  if (!filename) {
    // Download file if not exists
    const res = await fetch(url);
    const ext = mime.extension(res.headers.get("content-type")) || "unknown";
    filename = `file.${blockId}.${ext}`;

    const dest = path.join(downloadPath, filename);
    const file = fs.createWriteStream(dest);
    await once(res.body.pipe(file), "finish");
  }

  if (isImage) {
    // Get dimensions for Astro Image component
    const { width, height } = await getImageDimensions(filename, true);
    const params = new URLSearchParams({ w: width.toString(), h: height.toString() });
    const src = path.join("@assets", filename);
    return `{import("${src}")}?${params}`;
  }

  return path.join("/assets", filename);
}

Images

Downloaded to src/assets/ for Astro optimizationIncludes width/height metadata for responsive images

Other Media

Downloaded to public/assets/ for direct servingIncludes video, audio, PDF files

Type Safety

All Notion API responses use official TypeScript types:
import type {
  BlockObjectResponse,
  PageObjectResponse,
  DatabaseObjectResponse,
  PartialBlockObjectResponse,
  PartialPageObjectResponse,
  PartialDatabaseObjectResponse,
} from '@notionhq/client/build/src/api-endpoints';
The ensureFullResponse type guard eliminates partial responses:
export function ensureFullResponse<T, PT>(result: T | PT): T {
  return result as Extract<T, { parent: {} }>;
}

API Rate Limiting

The Notion API has rate limits (3 requests/second). The integration handles this through:
  1. Incremental sync: Only fetches changed content
  2. Caching: Data source IDs are cached
  3. Build-time only: No runtime API calls
If you encounter rate limits during development, consider implementing exponential backoff or reducing the number of databases being synced.

Error Handling

try {
  const blocks = await getBlock(pageId);
  // Process blocks
} catch (error) {
  console.error('Failed to fetch Notion content:', error);
  // Fall back to cached content or show error page
}
Always handle Notion API errors gracefully. Network issues or API changes can cause builds to fail.

Next Steps

Content Sync Process

Learn how Notion content is converted to MDX files

Architecture Overview

Return to the high-level architecture guide

Build docs developers (and LLMs) love