Skip to main content
RapidDev - Software Development Agency
bolt-ai-integrationsBolt Chat + API Route

How to Integrate Bolt.new with Notion

Connect Bolt.new to Notion using the Notion REST API with an internal integration token. Use Notion as a content backend — pull database items as blog posts, wiki articles, or project data into your Bolt app. All outbound Notion API calls work in Bolt's WebContainer preview. Share each database or page with your integration before querying it. Notion's block-based content requires extra parsing for rich text rendering.

What you'll learn

  • How to create a Notion internal integration and connect it to your databases
  • How to query Notion databases with filters and sorts via a Next.js API route
  • How to fetch and render Notion page content (blocks) in a React component
  • How to create new Notion pages and database entries from Bolt form submissions
  • How to handle Notion's block-based rich text format for clean HTML rendering
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate22 min read25 minutesProductivityApril 2026RapidDev Engineering Team
TL;DR

Connect Bolt.new to Notion using the Notion REST API with an internal integration token. Use Notion as a content backend — pull database items as blog posts, wiki articles, or project data into your Bolt app. All outbound Notion API calls work in Bolt's WebContainer preview. Share each database or page with your integration before querying it. Notion's block-based content requires extra parsing for rich text rendering.

Use Notion as a Headless CMS and Content Backend for Your Bolt.new App

Notion has become one of the most popular tools for teams to organize knowledge, manage projects, and create content. Its combination of structured databases (with filtering, sorting, and relational fields) and rich document pages makes it uniquely flexible as a backend. For Bolt-built applications, this means you can let non-technical team members create and edit content in Notion's familiar interface while your app queries and displays that content dynamically — essentially using Notion as a headless CMS without paying for a dedicated CMS subscription.

The Notion API v1 is a clean, well-documented REST API using bearer token authentication. Every workspace member can create internal integrations that receive an integration token — no OAuth flow needed for single-workspace apps. The main design decision in Notion's API is the separation between database queries (structured, filterable) and page content (block-based, recursive). Database items are easy to work with: query returns an array of page objects with typed properties matching your database columns. Page bodies are more complex: content is represented as a tree of blocks, each with a type (paragraph, heading_1, bulleted_list_item, code, image, etc.) and content, requiring recursive fetching to get full page trees.

For most Bolt integration use cases — blog backends, documentation portals, project trackers, content management — reading database properties is sufficient and avoids the complexity of block rendering. If you need to display full page bodies (articles, documentation), the @notionhq/client SDK's block-to-HTML conversion utilities simplify the process considerably. The combination of Notion's editing experience and Bolt's rapid UI generation makes this pairing particularly effective for content-driven internal tools.

Integration method

Bolt Chat + API Route

Bolt generates Next.js API routes that call Notion's REST API using an internal integration token stored in .env. The Notion API is JSON-based REST with bearer token auth — it works seamlessly from Bolt's WebContainer for all read and write operations. Each Notion database or page must be explicitly shared with the integration before it can be queried. Notion's block-based content model means page bodies require recursive fetching and HTML conversion for display.

Prerequisites

  • A Bolt.new account with a Next.js project
  • A Notion account at notion.so (free plan works)
  • A Notion database or page you want to connect — create one if you do not have one
  • A Notion internal integration token from notion.so/my-integrations
  • The database or page shared with your integration (connection added in Notion)

Step-by-step guide

1

Create a Notion Internal Integration and Get the Token

Notion integrations authenticate using a secret token tied to a specific workspace. Internal integrations are the simplest type — no OAuth flow, no redirect URIs, just a token you create and use immediately. They are appropriate for any app where you control both the Notion workspace and the application. To create an integration, go to notion.so/my-integrations and click New integration. Give it a descriptive name like 'Bolt App' or 'My Website Backend.' Select the workspace it should access from the dropdown — if you have multiple workspaces, choose the one containing your databases. Set the logo if you like (optional). Under Capabilities, review the permissions: Read content, Update content, and Insert content cover most use cases. Read user information without email is sufficient for fetching user details on pages; you rarely need email addresses from the API. Click Submit and you will see your integration's Internal Integration Secret, which starts with 'secret_'. Copy this immediately and add it to your Bolt project's .env file as NOTION_TOKEN. The critical step that many developers miss: you must explicitly share each Notion database or page with your integration before the API can access it. Open the Notion database you want to query, click the three-dot menu (or the Share button at the top right), then click Connections and search for your integration name. Click the integration to add the connection. Without this sharing step, every API call to that database will return a 404 error with 'Could not find database with id' — even though the database exists and your token is valid. Also note your Database ID. Open the Notion database in your browser. The URL has the format notion.so/{workspaceName}/{databaseId}?v={viewId}. The database ID is the 32-character string before the query mark, formatted as xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx. Add this to .env as NOTION_DATABASE_ID.

Bolt.new Prompt

Add NOTION_TOKEN and NOTION_DATABASE_ID to the .env file with placeholder values. Create a lib/notion.ts utility that exports a notionFetch helper. It should accept a path (e.g., '/databases/ID/query') and optional body, make the POST or GET request to https://api.notion.com/v1{path} with Authorization: Bearer and Notion-Version: 2022-06-28 headers, and return the parsed JSON. Also export a notionGet helper for GET requests and a notionPost helper that POSTs to create/query endpoints.

Paste this in Bolt.new chat

lib/notion.ts
1// lib/notion.ts
2const NOTION_BASE_URL = 'https://api.notion.com/v1';
3const NOTION_VERSION = '2022-06-28';
4
5function getHeaders() {
6 const token = process.env.NOTION_TOKEN;
7 if (!token) throw new Error('NOTION_TOKEN must be set in .env');
8
9 return {
10 Authorization: `Bearer ${token}`,
11 'Notion-Version': NOTION_VERSION,
12 'Content-Type': 'application/json',
13 };
14}
15
16export async function notionGet<T = unknown>(path: string): Promise<T> {
17 const response = await fetch(`${NOTION_BASE_URL}${path}`, {
18 headers: getHeaders(),
19 });
20 if (!response.ok) {
21 const err = await response.json().catch(() => ({})) as { message?: string };
22 throw new Error(err.message || `Notion API error: ${response.status}`);
23 }
24 return response.json() as Promise<T>;
25}
26
27export async function notionPost<T = unknown>(path: string, body?: unknown): Promise<T> {
28 const response = await fetch(`${NOTION_BASE_URL}${path}`, {
29 method: 'POST',
30 headers: getHeaders(),
31 body: body ? JSON.stringify(body) : undefined,
32 });
33 if (!response.ok) {
34 const err = await response.json().catch(() => ({})) as { message?: string };
35 throw new Error(err.message || `Notion API error: ${response.status}`);
36 }
37 return response.json() as Promise<T>;
38}
39
40export async function notionPatch<T = unknown>(path: string, body: unknown): Promise<T> {
41 const response = await fetch(`${NOTION_BASE_URL}${path}`, {
42 method: 'PATCH',
43 headers: getHeaders(),
44 body: JSON.stringify(body),
45 });
46 if (!response.ok) {
47 const err = await response.json().catch(() => ({})) as { message?: string };
48 throw new Error(err.message || `Notion API error: ${response.status}`);
49 }
50 return response.json() as Promise<T>;
51}

Pro tip: The Notion-Version header must be set to '2022-06-28' (the current stable version) on every request. Omitting this header causes the API to use the oldest version, which has different response shapes and missing features. Always include it explicitly.

Expected result: The lib/notion.ts helper is configured. Test the connection by calling notionGet('/users/me') from an API route — you should see the bot user profile for your integration, confirming the token is working.

2

Query a Notion Database via API Route

Querying a Notion database returns an array of page objects, each representing one row in the database. Each page object has a properties field containing all the column values for that row, with the structure varying by property type: title properties contain rich_text arrays; select properties contain a select object with name and color; date properties contain a date object with start and optionally end values; checkbox properties contain a boolean; number properties contain a number; relation properties contain an array of page IDs. The database query endpoint is POST /v1/databases/{database_id}/query. Despite being a data retrieval operation, Notion uses POST (not GET) for queries so you can include a complex filter and sort body. The filter object supports compound conditions using and and or arrays, individual property filters with field-specific filter types, and nested conditions. The sort array accepts property name plus direction pairs. For a simple published articles filter: pass a filter object with a property matching your status column name, a select filter type, and an equals condition for 'Published'. For date-sorted results, pass a sort array with the date property name and 'descending' direction. Notion returns a maximum of 100 results per query by default. For pagination, check the has_more boolean in the response. If true, use the next_cursor value as the start_cursor in the next query. For most content management use cases, 100 results are sufficient without pagination. The response structure from Notion can be verbose — each property value is wrapped in type-specific nested objects. Write a helper function that extracts the plain text value from each property type, converting Notion's property format to simple strings and primitives that are easier to work with in your React components. This mapping function dramatically simplifies your component code.

Bolt.new Prompt

Create a Next.js API route at app/api/notion/database/route.ts that queries a Notion database. Accept query params: databaseId (uses NOTION_DATABASE_ID env var if not provided), filter (optional JSON string), sortProperty (optional), sortDirection (optional, default 'descending'). POST to /v1/databases/{id}/query using notionPost from lib/notion.ts. Map each page's properties to a flat object — extract plain text from title, rich_text, and select properties; extract start string from date properties; extract the boolean from checkbox properties. Return { items: mappedPages[], total: number }. Cache for 60 seconds.

Paste this in Bolt.new chat

app/api/notion/database/route.ts
1// app/api/notion/database/route.ts
2import { NextResponse } from 'next/server';
3import { notionPost } from '@/lib/notion';
4
5type NotionPropertyValue =
6 | { type: 'title'; title: Array<{ plain_text: string }> }
7 | { type: 'rich_text'; rich_text: Array<{ plain_text: string }> }
8 | { type: 'select'; select: { name: string; color: string } | null }
9 | { type: 'multi_select'; multi_select: Array<{ name: string }> }
10 | { type: 'date'; date: { start: string; end: string | null } | null }
11 | { type: 'checkbox'; checkbox: boolean }
12 | { type: 'number'; number: number | null }
13 | { type: 'url'; url: string | null }
14 | { type: 'email'; email: string | null };
15
16function extractProperty(value: NotionPropertyValue): string | boolean | number | null {
17 if (!value) return null;
18 switch (value.type) {
19 case 'title': return value.title.map(t => t.plain_text).join('');
20 case 'rich_text': return value.rich_text.map(t => t.plain_text).join('');
21 case 'select': return value.select?.name ?? null;
22 case 'multi_select': return value.multi_select.map(s => s.name).join(', ');
23 case 'date': return value.date?.start ?? null;
24 case 'checkbox': return value.checkbox;
25 case 'number': return value.number;
26 case 'url': return value.url;
27 case 'email': return value.email;
28 default: return null;
29 }
30}
31
32const cache = new Map<string, { data: unknown; expiresAt: number }>();
33
34export async function GET(request: Request) {
35 const { searchParams } = new URL(request.url);
36 const databaseId = searchParams.get('databaseId') || process.env.NOTION_DATABASE_ID;
37 const sortProperty = searchParams.get('sortProperty');
38 const sortDirection = searchParams.get('sortDirection') || 'descending';
39
40 if (!databaseId) {
41 return NextResponse.json({ error: 'databaseId is required' }, { status: 400 });
42 }
43
44 const cacheKey = `${databaseId}-${sortProperty}-${sortDirection}`;
45 const cached = cache.get(cacheKey);
46 if (cached && Date.now() < cached.expiresAt) {
47 return NextResponse.json(cached.data);
48 }
49
50 try {
51 const body: Record<string, unknown> = {};
52 if (sortProperty) {
53 body.sorts = [{ property: sortProperty, direction: sortDirection }];
54 }
55
56 const data = await notionPost<{ results: Array<{ id: string; properties: Record<string, NotionPropertyValue> }>; has_more: boolean }>(
57 `/databases/${databaseId}/query`,
58 body
59 );
60
61 const items = data.results.map((page) => ({
62 id: page.id,
63 ...Object.fromEntries(
64 Object.entries(page.properties).map(([key, val]) => [key, extractProperty(val)])
65 ),
66 }));
67
68 const result = { items, total: items.length };
69 cache.set(cacheKey, { data: result, expiresAt: Date.now() + 60_000 });
70 return NextResponse.json(result);
71 } catch (err) {
72 const message = err instanceof Error ? err.message : 'Failed to query Notion database';
73 return NextResponse.json({ error: message }, { status: 500 });
74 }
75}

Pro tip: Notion property names in the API response match the column names in your database exactly, including case and spaces. If your column is called 'Publish Date', the response key is 'Publish Date' — not 'publishDate' or 'publish_date'. Use bracket notation in JavaScript to access properties with spaces: page.properties['Publish Date'].

Expected result: The API route queries your Notion database and returns a flat array of items with extracted property values. Test it in the Bolt preview by hitting the route URL and verifying the items array matches your Notion database rows.

3

Fetch and Render Notion Page Block Content

Notion pages are composed of blocks arranged in a tree structure. To display the body of a Notion page — an article, documentation page, or wiki entry — you need to fetch the page's child blocks recursively and convert them to renderable HTML or React elements. The blocks endpoint is GET /v1/blocks/{block_id}/children. Pass the page ID as the block ID to get top-level blocks. Each block object has a type field and a corresponding data field with the same name (a paragraph block has a paragraph field, a heading_1 block has a heading_1 field, etc.). Text content within blocks uses Notion's rich_text format — an array of text objects with the actual content in plain_text and optional annotations for bold, italic, underline, strikethrough, and code formatting. Some block types contain child blocks (toggle blocks, bulleted lists, numbered lists, columns). For full page rendering, you need to recursively fetch child blocks for any block with has_children set to true. This can require multiple API calls for complex pages. For simple content pages (primarily paragraphs and headings without nested structures), a single blocks fetch is sufficient. For a practical rendering implementation, iterate the blocks array and convert each block type to an HTML string or React element. Handle the essential types: paragraph, heading_1, heading_2, heading_3, bulleted_list_item, numbered_list_item, to_do, code, image, callout, quote, and divider. Rich text annotations (bold, italic, code inline) require wrapping the text segments in appropriate HTML tags like strong, em, and code. For code blocks, Notion includes a language field — use this to add the appropriate language class for syntax highlighting with Prism.js or Highlight.js. Image blocks contain a file object with a url that expires after one hour — do not cache image URLs for longer than that.

Bolt.new Prompt

Create a Next.js API route at app/api/notion/page/[id]/route.ts that fetches a Notion page's blocks and converts them to HTML. Fetch blocks from /v1/blocks/{id}/children. For each block, convert to HTML: paragraph → <p>, heading_1/2/3 → h1/h2/h3, bulleted_list_item → <li> (wrap consecutive items in <ul>), numbered_list_item → <li> in <ol>, code → <pre><code class='language-{lang}'>, image → <img src={url} alt=''>, callout → <div class='callout'>, quote → <blockquote>, divider → <hr>. Convert rich_text arrays to HTML strings handling bold, italic, and inline code annotations. Return { html: string, title: string }.

Paste this in Bolt.new chat

app/api/notion/page/[id]/route.ts
1// app/api/notion/page/[id]/route.ts
2import { NextResponse } from 'next/server';
3import { notionGet } from '@/lib/notion';
4
5interface RichText {
6 plain_text: string;
7 annotations: { bold: boolean; italic: boolean; code: boolean; strikethrough: boolean };
8 href: string | null;
9}
10
11function richTextToHtml(richText: RichText[]): string {
12 return richText
13 .map((t) => {
14 let text = t.plain_text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
15 if (t.annotations.code) text = `<code>${text}</code>`;
16 if (t.annotations.bold) text = `<strong>${text}</strong>`;
17 if (t.annotations.italic) text = `<em>${text}</em>`;
18 if (t.annotations.strikethrough) text = `<del>${text}</del>`;
19 if (t.href) text = `<a href="${t.href}">${text}</a>`;
20 return text;
21 })
22 .join('');
23}
24
25type Block = {
26 type: string;
27 id: string;
28 has_children: boolean;
29 [key: string]: unknown;
30};
31
32function blockToHtml(block: Block): string {
33 const type = block.type;
34 const data = block[type] as Record<string, unknown>;
35 const rt = (data?.rich_text as RichText[]) || [];
36 const text = richTextToHtml(rt);
37
38 switch (type) {
39 case 'paragraph': return `<p>${text || '&nbsp;'}</p>`;
40 case 'heading_1': return `<h1>${text}</h1>`;
41 case 'heading_2': return `<h2>${text}</h2>`;
42 case 'heading_3': return `<h3>${text}</h3>`;
43 case 'bulleted_list_item': return `<li>${text}</li>`;
44 case 'numbered_list_item': return `<li>${text}</li>`;
45 case 'code': {
46 const lang = (data?.language as string) || 'text';
47 return `<pre><code class="language-${lang}">${(data?.rich_text as RichText[]).map(t => t.plain_text).join('')}</code></pre>`;
48 }
49 case 'image': {
50 const imgData = data as { type: string; file?: { url: string }; external?: { url: string } };
51 const url = imgData.type === 'external' ? imgData.external?.url : imgData.file?.url;
52 return `<img src="${url}" alt="" style="max-width:100%" />`;
53 }
54 case 'callout': return `<div class="callout">${text}</div>`;
55 case 'quote': return `<blockquote>${text}</blockquote>`;
56 case 'divider': return '<hr />';
57 case 'to_do': {
58 const checked = (data?.checked as boolean) ? 'checked' : '';
59 return `<label><input type="checkbox" ${checked} disabled /> ${text}</label>`;
60 }
61 default: return text ? `<p>${text}</p>` : '';
62 }
63}
64
65export async function GET(
66 _request: Request,
67 { params }: { params: { id: string } }
68) {
69 try {
70 const [pageData, blocksData] = await Promise.all([
71 notionGet<{ properties: Record<string, { title?: Array<{ plain_text: string }> }> }>(`/pages/${params.id}`),
72 notionGet<{ results: Block[] }>(`/blocks/${params.id}/children`),
73 ]);
74
75 const titleProp = Object.values(pageData.properties).find(p => p.title);
76 const title = titleProp?.title?.map(t => t.plain_text).join('') || 'Untitled';
77
78 const blocks = blocksData.results;
79 let html = '';
80 let inUl = false;
81 let inOl = false;
82
83 for (const block of blocks) {
84 if (block.type === 'bulleted_list_item') {
85 if (!inUl) { html += '<ul>'; inUl = true; }
86 html += blockToHtml(block);
87 } else {
88 if (inUl) { html += '</ul>'; inUl = false; }
89 if (block.type === 'numbered_list_item') {
90 if (!inOl) { html += '<ol>'; inOl = true; }
91 html += blockToHtml(block);
92 } else {
93 if (inOl) { html += '</ol>'; inOl = false; }
94 html += blockToHtml(block);
95 }
96 }
97 }
98 if (inUl) html += '</ul>';
99 if (inOl) html += '</ol>';
100
101 return NextResponse.json({ title, html });
102 } catch (err) {
103 const message = err instanceof Error ? err.message : 'Failed to fetch page';
104 return NextResponse.json({ error: message }, { status: 500 });
105 }
106}

Pro tip: Notion image URLs expire after approximately one hour — do not store or cache these URLs beyond that window. For production apps with images, either proxy the images through your own API route (fetching fresh URLs on demand) or copy the images to your own storage (Supabase Storage or S3) and reference those permanent URLs instead.

Expected result: The page route fetches a Notion page's block content and returns it as an HTML string. Test by passing a page ID from your database results — the response should include the page title and HTML-formatted body content ready for rendering with dangerouslySetInnerHTML.

4

Create Notion Pages from Bolt Form Submissions

Creating new pages in a Notion database via the API allows your Bolt app to act as an intake form for Notion workflows. Form submissions, user requests, lead captures, and content submissions can all create structured records in your Notion database, ready for team review in Notion's native interface. The create page endpoint is POST /v1/pages. The request body requires a parent object specifying the database ID, a properties object containing the new page's property values (matching your database schema exactly), and optionally a children array for the page body content. Properties in the create payload use a typed structure that mirrors the read format. Title properties require a title array of rich_text objects. Select properties require a select object with the option name. Date properties require a date object with a start string. Number properties require a number value directly. Checkbox properties require a boolean. Validation before the API call is important because Notion returns 400 errors for invalid property values — attempting to set a select option that does not exist in the database's defined options list returns an error. Fetch the database schema first (GET /v1/databases/{id}) to get the list of valid select options if you are rendering option choices dynamically. For the page body content (optional but useful for notes or descriptions), include a children array with block objects. The create and blocks API use the same block format, so you can create a page with pre-populated content paragraphs, checklists, or any other supported block type in a single request. This is more efficient than creating the page first and then appending blocks separately.

Bolt.new Prompt

Create a Next.js API route at app/api/notion/create/route.ts that creates a new page in a Notion database. Accept POST with a JSON body containing the page fields. Use the NOTION_DATABASE_ID env var. Create the page using notionPost to /v1/pages with the parent database and properties. Map incoming fields to Notion property format: string fields as title or rich_text, string selects as select property, boolean as checkbox. Return the created page ID and URL. Also build a simple React submission form component that calls this route.

Paste this in Bolt.new chat

app/api/notion/create/route.ts
1// app/api/notion/create/route.ts
2import { NextResponse } from 'next/server';
3import { notionPost } from '@/lib/notion';
4
5interface CreatePageBody {
6 title: string;
7 status?: string;
8 priority?: string;
9 notes?: string;
10 dueDate?: string;
11}
12
13export async function POST(request: Request) {
14 const body = await request.json() as CreatePageBody;
15 const databaseId = process.env.NOTION_DATABASE_ID;
16
17 if (!databaseId) {
18 return NextResponse.json({ error: 'NOTION_DATABASE_ID is not configured' }, { status: 500 });
19 }
20
21 if (!body.title?.trim()) {
22 return NextResponse.json({ error: 'title is required' }, { status: 400 });
23 }
24
25 const properties: Record<string, unknown> = {
26 Name: {
27 title: [{ text: { content: body.title } }],
28 },
29 };
30
31 if (body.status) {
32 properties.Status = { select: { name: body.status } };
33 }
34 if (body.priority) {
35 properties.Priority = { select: { name: body.priority } };
36 }
37 if (body.dueDate) {
38 properties['Due Date'] = { date: { start: body.dueDate } };
39 }
40
41 const pageBody: Record<string, unknown> = {
42 parent: { database_id: databaseId },
43 properties,
44 };
45
46 if (body.notes?.trim()) {
47 pageBody.children = [
48 {
49 object: 'block',
50 type: 'paragraph',
51 paragraph: {
52 rich_text: [{ type: 'text', text: { content: body.notes } }],
53 },
54 },
55 ];
56 }
57
58 try {
59 const page = await notionPost<{ id: string; url: string }>('/pages', pageBody);
60 return NextResponse.json({ success: true, id: page.id, url: page.url });
61 } catch (err) {
62 const message = err instanceof Error ? err.message : 'Failed to create page';
63 return NextResponse.json({ error: message }, { status: 500 });
64 }
65}

Pro tip: When setting a Select property, the option name must exactly match one of the options defined in your Notion database schema, including capitalization. If you try to set a select value that is not in the existing options list, Notion returns a 400 error. Either restrict your form's select choices to the existing Notion options, or prefetch the database schema to populate the dropdown dynamically.

Expected result: Submitting the form creates a new page in your Notion database. Open Notion and refresh the database view — the new entry should appear with all the properties you submitted. The API route returns the new page's ID and the direct Notion URL.

Common use cases

Notion-Powered Blog or Documentation Site

Use a Notion database as the content management system for a blog or documentation site. Team members write posts and docs in Notion, setting properties like title, category, publish date, and published status in the database columns. Your Bolt app queries only published items, renders the list view from database properties, and fetches full page block content for individual article pages.

Bolt.new Prompt

Build a blog powered by Notion. Create /api/notion/posts that queries a Notion database using POST to https://api.notion.com/v1/databases/{DATABASE_ID}/query with a filter for Status = 'Published', sorted by PublishDate descending. Return title, slug, category, publishDate, excerpt, and coverImage from page properties. Create /api/notion/posts/[id] that fetches page blocks from /v1/blocks/{id}/children and converts them to a simple HTML string (handle paragraph, heading_1-3, bulleted_list_item, numbered_list_item, code, and image block types). Build a blog index page and a post detail page. Store NOTION_TOKEN and NOTION_DATABASE_ID in process.env.

Copy this prompt to try it in Bolt.new

Project Tracker from Notion Database

Build a custom project tracker view that reads project data from a Notion database and presents it with a more focused interface than Notion's native views. Filter and sort projects by status, owner, or deadline. Display a Kanban-style board or a priority-sorted list. Let team members update project status directly from the Bolt interface via the Notion API's page update endpoint.

Bolt.new Prompt

Create a project tracker dashboard using a Notion database as the backend. Build /api/notion/projects that queries the database with sorting by Priority (High/Medium/Low) and filtering to exclude Archived projects. Map Notion properties to: name, status, owner, dueDate, priority, description. Build a React Kanban board with columns for each status value (Planning, In Progress, Review, Done). Each card shows the project name, owner avatar initials, priority badge, and due date. Clicking a card opens a detail panel with the full description. Add a status dropdown on each card that calls PATCH /api/notion/projects/[id]/status to update the Status property.

Copy this prompt to try it in Bolt.new

Content Submission Form that Creates Notion Pages

Build a form that creates new entries in a Notion database on submission — useful for content submission workflows, team request trackers, or lead capture systems. The form collects structured data that maps to Notion database properties, and the API route creates a new page in the database with those property values set.

Bolt.new Prompt

Build a content submission form that creates Notion database entries. Create /api/notion/submit that accepts POST with { title, category, content, authorName, authorEmail } and calls POST to https://api.notion.com/v1/pages with parent: { database_id: NOTION_DATABASE_ID }, properties for Title, Category (select), Author, Email, and Status set to 'Pending', plus the content as a paragraph block in the page body. Build a React form with title text input, category selector, content textarea, author name, and email fields. Show a success message with a link to view the submission in Notion after creation.

Copy this prompt to try it in Bolt.new

Troubleshooting

API returns 404 with 'Could not find database with id' even though the database exists

Cause: The Notion integration has not been given access to the database. Every database must be explicitly connected to the integration — Notion does not automatically grant integrations access to all databases in the workspace.

Solution: Open the Notion database, click the three-dot menu at the top right (or the Share button), select Connections, and search for your integration name. Click it to add the connection. If you created the integration recently, you may need to refresh the Connections list. After adding the connection, the API should be able to query the database immediately.

Properties in API response have unexpected structure or missing values

Cause: Notion property values are wrapped in type-specific nested objects. Reading a title property as a plain string fails — the actual text is nested inside page.properties.Name.title[0].plain_text. Different property types have completely different structures.

Solution: Use the extractProperty helper function to normalize all property types to simple values. Log the raw API response first to understand the exact structure of your specific database columns, then write type-specific extraction logic for each property type you need.

typescript
1// For a title property named 'Name':
2const name = page.properties.Name.title.map(t => t.plain_text).join('');
3// For a select property named 'Status':
4const status = page.properties.Status.select?.name;
5// For a date property named 'Due Date':
6const dueDate = page.properties['Due Date'].date?.start;

Creating a page fails with 400 and 'body.properties.Status.select.name should be a valid select option name'

Cause: The select option name in the create request does not match any existing option in the database column. Notion select columns have a predefined list of options, and the API rejects any option name not in that list.

Solution: Fetch the database schema with GET /v1/databases/{id} and read the properties.Status.select.options array to get the exact option names. Use these names in your form's dropdown and in the API payload. Alternatively, prompt Bolt to add a new option to the Notion database column directly.

typescript
1// Fetch valid select options before creating:
2const db = await notionGet(`/databases/${databaseId}`);
3const statusOptions = db.properties.Status.select.options.map(o => o.name);
4// Use statusOptions to populate your form dropdown

Page content (blocks) not rendering correctly — blocks appear as empty paragraphs

Cause: Nested blocks (list items inside a toggle, text inside a column) require recursive fetching. The initial /blocks/{id}/children call only returns the top-level blocks — blocks with has_children: true contain additional child blocks that must be fetched with a second API call.

Solution: For complex pages with toggles, columns, or nested lists, recursively fetch child blocks for any block where has_children is true. For simple pages with only paragraphs and headings, the single fetch is sufficient. Check the has_children field on each block in your initial response to determine if additional fetching is needed.

typescript
1// Recursive block fetching for complex pages:
2async function fetchBlocksRecursively(blockId: string): Promise<Block[]> {
3 const data = await notionGet<{ results: Block[] }>(`/blocks/${blockId}/children`);
4 const blocks = data.results;
5 for (const block of blocks) {
6 if (block.has_children) {
7 block.children = await fetchBlocksRecursively(block.id);
8 }
9 }
10 return blocks;
11}

Best practices

  • Always share each Notion database or page with your integration explicitly — the API does not discover databases automatically, and missing this step is the single most common setup error.
  • Never expose NOTION_TOKEN with a NEXT_PUBLIC_ prefix — integration tokens grant full write access to your workspace and must only be used server-side in API routes.
  • Cache Notion API responses for 60 seconds or more — the API has rate limits of 3 requests per second and most content (blog posts, documentation) does not change in real time.
  • Define TypeScript interfaces for your specific Notion database schema to get type safety when extracting property values — the generic response types are useful but database-specific types catch errors early.
  • Use Notion Views to pre-filter and pre-sort database content — the API supports a filter_properties query param and you can build filters that mirror your saved views, reducing query complexity in code.
  • Handle Notion's pagination — databases with more than 100 entries return a next_cursor; always check has_more in responses and implement cursor-based pagination for complete data access.
  • Avoid storing Notion image URLs longer than one hour — file URLs (not external URLs) expire and must be freshly fetched each session.
  • Test page creation with a dedicated test database before connecting to production data — Notion has no undo for API-created pages, and bulk creation errors can pollute a real workspace database.

Alternatives

Frequently asked questions

Can I use the Notion API in Bolt's WebContainer preview without deploying?

Yes — all outbound Notion API calls work from Next.js API routes in the Bolt WebContainer. You can query databases, fetch page content, and create new pages during development. Notion does not have a webhook feature that requires incoming connections, so there is no deployment requirement for standard read/write operations.

Does Bolt.new have a native Notion integration?

No — Bolt.new does not include a built-in Notion connector. The integration uses Notion's REST API directly from Next.js API routes with an internal integration token. Bolt's AI can generate all the integration code from a description of your use case, making setup fast even without a native connector.

How do I access a Notion database I did not create?

Ask a workspace member with edit access to the database to add your integration as a connection. Open the database, click Share, go to Connections, and add the integration. Integration access to Notion is database-specific, not workspace-wide, so each database must be shared individually. You cannot access a database that has not been explicitly connected to your integration.

What is the Notion API rate limit?

The Notion API allows up to 3 requests per second per integration. For dashboard pages that make multiple parallel requests on load, this limit can be reached quickly. Add a 60-second in-memory cache on your API routes to reduce Notion API calls significantly. For high-traffic apps, consider scheduling periodic data syncs to a local database rather than querying Notion on every user request.

Can I use Notion as a CMS for a public website built with Bolt?

Yes — this is one of the most common Notion integration patterns. Create a Notion database with your content (blog posts, portfolio items, product listings), write your Bolt app to fetch and display it, and have non-technical team members manage all content in Notion. The main consideration is caching: cache Notion responses for at least 60 seconds to stay within rate limits and reduce latency for public site visitors.

How do I render Notion page content with proper formatting in React?

Fetch the page blocks from /v1/blocks/{pageId}/children and convert each block type to HTML or React elements. The block-to-HTML approach returns an HTML string you render with dangerouslySetInnerHTML (safe since you control the Notion content). Alternatively, use the @notionhq/client SDK's page rendering utilities, or the notion-to-md npm package which converts Notion blocks to Markdown for display with a Markdown renderer like react-markdown.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.