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.
src/lib/notion-client.ts
Usage
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:
Notion’s last modification timestamp, used for incremental sync
Publication date in YYYY-MM-DD format
SEO meta description for the post/project
URL slug (e.g., “my-blog-post”)
Comma-separated tags for categorization
Visibility toggle - only public items are synced
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 optimization Includes width/height metadata for responsive images
Other Media Downloaded to public/assets/ for direct serving Includes 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:
Incremental sync : Only fetches changed content
Caching : Data source IDs are cached
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