# How to Integrate Notion with V0

- Tool: V0
- Difficulty: Intermediate
- Time required: 20 minutes
- Last updated: April 2026

## TL;DR

To use Notion as a headless CMS with V0 by Vercel, create a Next.js API route at app/api/notion/route.ts that queries Notion databases using the official @notionhq/client SDK with an integration token stored in NOTION_TOKEN. V0 generates blog, portfolio, or content pages that fetch from your API route. This lets non-technical team members manage content in Notion while your V0 app displays it with a custom design.

## Using Notion as a Headless CMS for Your V0 App

Notion has become a popular choice for headless CMS because non-technical team members already know how to use it. A marketing manager can update blog posts, a product manager can edit the features list, and a founder can manage the FAQ — all without touching code or learning a new CMS interface. Your V0 app fetches the latest content from Notion and displays it with your custom design.

The integration uses Notion's official API (notion.so/my-integrations) and the @notionhq/client npm package. You create an internal integration, get a token, and share your Notion database with that integration. The Next.js API route on Vercel queries the database using the SDK and returns structured data that your V0 React components can render.

Notionhq's API returns content in a rich but verbose format — properties are typed objects with nested value structures. A simple text field returns as { type: 'rich_text', rich_text: [{ plain_text: 'Hello' }] } rather than just the string 'Hello'. Your API route should transform this structure into clean, flat JSON that makes React components simple to write. V0 generates better components when given clean data shapes.

## Before you start

- A V0 account with a Next.js project at v0.dev
- A Notion account at notion.so with a database containing the content you want to display
- A Notion integration created at notion.so/my-integrations with your database shared to that integration
- Your Notion Integration Token and the Database ID you want to query
- A Vercel account with your V0 project connected via GitHub

## Step-by-step guide

### 1. Create a Notion Integration and Get Your Token

Notion uses internal integrations to grant API access to specific databases. You create an integration, get a token, and then explicitly share each database with that integration.

Go to https://www.notion.so/my-integrations in your browser (you must be logged into Notion). Click 'New integration'. Give it a name like 'V0 Website' and select the Notion workspace that contains your content database. Choose 'Internal' as the integration type. Under 'Content Capabilities', ensure 'Read content' is checked (you can also add 'Update content' if you want to write back to Notion). Click 'Submit'.

After creating the integration, you will see your Integration Token on the integration's page. It starts with 'secret_' followed by a long alphanumeric string. Copy this token — it is displayed once and you will need to regenerate it if you lose it.

Now share your database with the integration. Open your Notion database in the browser. Click the '...' (ellipsis) menu in the top-right corner of the database. Scroll down and click 'Add connections'. Search for your integration name (e.g., 'V0 Website') and click to add it. A confirmation dialog will appear — click 'Confirm'. The database is now accessible via the API using your integration token.

To find your Database ID, open the database in Notion and look at the URL. For a full-page database, the URL looks like https://www.notion.so/myworkspace/{DATABASE_ID}?v=... The 32-character string before the '?' is your Database ID. For inline databases, navigate to the database's full page view first. You can format the ID with hyphens (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx) or without — both formats work with the SDK.

**Expected result:** You have your Notion Integration Token (starts with 'secret_') and your Database ID (32-character hex string). The database has been shared with the integration via the '...' → 'Add connections' menu.

### 2. Install the Notion SDK and Add Environment Variables

The official @notionhq/client package handles all the authentication, pagination, and API communication details for you. Add it to your project and configure environment variables.

In your V0 project, open the code editor and add @notionhq/client to your package.json dependencies. If you are working locally, run npm install @notionhq/client. If you are editing in V0 directly, use the V0 prompt to ask it to add the package.

Next, add your Notion credentials to Vercel. Open your Vercel Dashboard, navigate to your project, click 'Settings', and select 'Environment Variables'. Add:

NOTION_TOKEN: Your integration token starting with 'secret_'. This is a secret — do not add NEXT_PUBLIC_ prefix.

NOTION_DATABASE_ID: Your 32-character Database ID. If you have multiple databases for different content types (blog, FAQ, roadmap), add separate variables like NOTION_BLOG_DATABASE_ID and NOTION_FAQ_DATABASE_ID.

Save and redeploy. Add the same variables to .env.local for local development.

```
// lib/notion.ts
import { Client } from '@notionhq/client';

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

// Helper to extract plain text from Notion rich text property
export function getRichText(property: { rich_text: { plain_text: string }[] } | undefined): string {
  return property?.rich_text?.map((t) => t.plain_text).join('') || '';
}

// Helper to extract text from Notion title property
export function getTitle(property: { title: { plain_text: string }[] } | undefined): string {
  return property?.title?.map((t) => t.plain_text).join('') || '';
}

// Helper to extract select value
export function getSelect(property: { select: { name: string } | null } | undefined): string {
  return property?.select?.name || '';
}

// Helper to extract date value
export function getDate(property: { date: { start: string } | null } | undefined): string | null {
  return property?.date?.start || null;
}

// Helper to extract files/media URLs
export function getFileUrl(property: { files: { type: string; file?: { url: string }; external?: { url: string } }[] } | undefined): string | null {
  const file = property?.files?.[0];
  if (!file) return null;
  return file.type === 'external' ? file.external?.url || null : file.file?.url || null;
}
```

**Expected result:** The @notionhq/client package is installed. NOTION_TOKEN and NOTION_DATABASE_ID are saved in Vercel. The lib/notion.ts utility file is created with helper functions for common property types.

### 3. Create the Notion Database API Route

Create the Next.js API route that queries your Notion database and returns clean, component-ready JSON. The route uses the Notion SDK's databases.query() method which supports filtering, sorting, and pagination.

Create app/api/notion/database/route.ts. The SDK's notion.databases.query() method takes a database_id and optional filter and sorts objects. Notion's filter syntax uses property-specific filter conditions nested under the property name and type.

For a blog post database, you typically filter for posts where Status (a Select property) equals 'Published'. The filter object looks like: { property: 'Status', select: { equals: 'Published' } }. For date-based sorting, add sorts: [{ property: 'PublishDate', direction: 'descending' }].

The results from databases.query() are Notion Page objects. Each page has a properties object containing all your database columns as typed property objects. Your route should transform these into a flat, clean object using the helper functions from lib/notion.ts. This transformation is the most important part — the components you generate with V0 should not need to understand Notion's verbose property format.

Notionhq's API returns a maximum of 100 pages per request. If you need more, use the has_more and next_cursor fields to implement pagination. For most websites, 100 items is sufficient for blog posts, FAQs, or roadmap items.

```
import { NextResponse } from 'next/server';
import { notion, getTitle, getRichText, getSelect, getDate, getFileUrl } from '@/lib/notion';
import type { PageObjectResponse } from '@notionhq/client/build/src/api-endpoints';

export async function GET() {
  const databaseId = process.env.NOTION_DATABASE_ID;

  if (!databaseId) {
    return NextResponse.json(
      { error: 'Notion database ID not configured' },
      { status: 500 }
    );
  }

  try {
    const response = await notion.databases.query({
      database_id: databaseId,
      filter: {
        property: 'Status',
        select: {
          equals: 'Published',
        },
      },
      sorts: [
        {
          property: 'PublishDate',
          direction: 'descending',
        },
      ],
    });

    const pages = response.results.filter(
      (page): page is PageObjectResponse => page.object === 'page'
    );

    const posts = pages.map((page) => {
      const props = page.properties as Record<string, unknown>;

      return {
        id: page.id,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        title: getTitle(props['Title'] as any),
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        slug: getRichText(props['Slug'] as any) || page.id,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        author: getRichText(props['Author'] as any),
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        publishDate: getDate(props['PublishDate'] as any),
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        category: getSelect(props['Category'] as any),
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        coverImageUrl: getFileUrl(props['CoverImage'] as any),
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        excerpt: getRichText(props['Excerpt'] as any),
        notionUrl: page.url,
        lastEdited: page.last_edited_time,
      };
    });

    return NextResponse.json(
      { posts, total: posts.length },
      {
        headers: {
          // Cache for 5 minutes, allow stale while revalidating
          'Cache-Control': 's-maxage=300, stale-while-revalidate=600',
        },
      }
    );
  } catch (error) {
    console.error('Notion API query failed:', error);
    return NextResponse.json(
      { error: 'Failed to fetch content from Notion' },
      { status: 500 }
    );
  }
}
```

**Expected result:** Calling /api/notion/database returns a JSON array of published posts with clean, flat property values — title, slug, author, publishDate, category, coverImageUrl, and excerpt for each post.

### 4. Generate Content Pages with V0

With clean data coming from the Notion API route, prompt V0 to generate the content listing and detail pages. The key is giving V0 the exact data shape returned by your API route so it generates components that handle your specific fields correctly.

For a blog listing, describe the card layout, typography, and interactive elements. Tell V0 what to do with optional fields — for example, show a gradient placeholder if coverImageUrl is null, or hide the category badge if category is empty.

For individual post pages, you will need a separate API route that fetches the page content (blocks) in addition to metadata. The Notion SDK's blocks.children.list() method retrieves the page body as an array of block objects (paragraphs, headings, images, etc.). Rendering Notion blocks as HTML requires handling each block type — use the notion-to-md or react-notion-x package to simplify this, or ask V0 to generate a block renderer for common types (paragraph, heading_1/2/3, bulleted_list_item, numbered_list_item, image).

For simpler content like FAQs or feature lists where the content fits in database properties (not page body), you do not need block rendering — all the content comes from properties.

Ask V0 to implement Next.js Static Site Generation (SSG) with revalidation for blog pages using async generateStaticParams() and route segment config export const revalidate = 300. This builds static pages at deploy time and regenerates them in the background every 5 minutes, giving you fast page loads with fresh content.

**Expected result:** V0 generates a polished blog listing page with category filters, cover images, author information, and 'Read more' links. The page fetches from your Notion database through the API route.

### 5. Add a Page Content Route for Full Posts

To display the full content of a Notion page (not just its database properties), create a route that fetches the page's block children. Notion stores page body content as a tree of block objects.

Create app/api/notion/page/[id]/route.ts. Use notion.blocks.children.list({ block_id: id }) to retrieve all block objects for a page. Each block has a type and a corresponding property object containing the content.

Common block types you will need to handle: paragraph, heading_1, heading_2, heading_3, bulleted_list_item, numbered_list_item, toggle, quote, code, image, divider, and callout. Each has a rich_text array for text content and type-specific properties (e.g., image blocks have a url).

For a simpler approach, install the notion-to-md package (npm install notion-to-md) which converts Notion blocks to Markdown, which you can then render with a Markdown renderer like react-markdown.

For teams using Notion as a CMS for high-traffic websites with many content editors, RapidDev can help set up a more robust architecture with proper caching, incremental static regeneration, and webhook-triggered rebuilds when Notion content changes.

```
import { NextRequest, NextResponse } from 'next/server';
import { notion } from '@/lib/notion';
import type { BlockObjectResponse } from '@notionhq/client/build/src/api-endpoints';

function blockToMarkdown(block: BlockObjectResponse): string {
  const richText = (arr: { plain_text: string }[]) =>
    arr.map((t) => t.plain_text).join('');

  switch (block.type) {
    case 'paragraph':
      return richText(block.paragraph.rich_text) + '\n\n';
    case 'heading_1':
      return '# ' + richText(block.heading_1.rich_text) + '\n\n';
    case 'heading_2':
      return '## ' + richText(block.heading_2.rich_text) + '\n\n';
    case 'heading_3':
      return '### ' + richText(block.heading_3.rich_text) + '\n\n';
    case 'bulleted_list_item':
      return '- ' + richText(block.bulleted_list_item.rich_text) + '\n';
    case 'numbered_list_item':
      return '1. ' + richText(block.numbered_list_item.rich_text) + '\n';
    case 'quote':
      return '> ' + richText(block.quote.rich_text) + '\n\n';
    case 'code':
      return '```' + block.code.language + '\n' +
        richText(block.code.rich_text) + '\n```\n\n';
    case 'image': {
      const url = block.image.type === 'external'
        ? block.image.external.url
        : block.image.file.url;
      const caption = richText(block.image.caption);
      return `![${caption}](${url})\n\n`;
    }
    case 'divider':
      return '---\n\n';
    default:
      return '';
  }
}

export async function GET(
  _request: NextRequest,
  { params }: { params: { id: string } }
) {
  const { id } = params;

  if (!id) {
    return NextResponse.json({ error: 'Page ID required' }, { status: 400 });
  }

  try {
    const blocksResponse = await notion.blocks.children.list({
      block_id: id,
      page_size: 100,
    });

    const markdown = (blocksResponse.results as BlockObjectResponse[])
      .map(blockToMarkdown)
      .join('');

    return NextResponse.json({ markdown, blockCount: blocksResponse.results.length });
  } catch (error) {
    console.error('Notion page fetch failed:', error);
    return NextResponse.json(
      { error: 'Failed to fetch page content' },
      { status: 500 }
    );
  }
}
```

**Expected result:** Calling /api/notion/page/{notionPageId} returns the page body as a Markdown string that react-markdown can render. Headings, paragraphs, lists, code blocks, and images are converted to Markdown format.

## Best practices

- Always share each Notion database explicitly with your integration via the '...' → 'Add connections' menu — the integration token alone does not grant automatic access to all databases.
- Transform Notion's verbose property format into clean flat objects in your API route — components should receive simple strings and dates, not nested Notion property objects.
- Use external image URLs (Cloudinary, Imgix, etc.) in your Notion database rather than uploading files directly, since Notion's signed file URLs expire after one hour.
- Cache Notion responses for 5 minutes or more (next: { revalidate: 300 }) — Notion's API has rate limits and content pages rarely need real-time freshness.
- Filter for published status in your Notion query filter rather than fetching all pages and filtering in JavaScript — this reduces payload size and protects draft content from appearing publicly.
- Set up a Notion webhook or schedule ISR revalidation so content updates in Notion appear on your V0 site without a full redeploy.
- For team members who need to know how to format content, create a template page in your Notion database with instructions for filling in required fields like Slug, Status, and PublishDate.

## Use cases

### Team Blog Powered by Notion

A startup wants their engineering team to write blog posts in Notion and have them automatically appear on the company website. V0 generates a blog listing page and individual post pages. The Notion database has columns for Title, Status, PublishDate, Author, Category, CoverImage, and Slug. Only posts with Status='Published' appear on the site.

Prompt example:

```
Build a blog listing page that fetches posts from /api/notion/posts. Each post has title, slug, author, category, publishDate (ISO string), coverImageUrl, and excerpt. Display posts as a vertical list of cards with the cover image on the left, title as a bold heading, author and date in muted text below, and category as a colored tag. Clicking a card navigates to /blog/{slug}. Show posts sorted by publishDate descending. Add a category filter row above the list.
```

### Product Roadmap from Notion Database

A product team manages their roadmap in a Notion database with columns for Feature, Status, Quarter, Team, and Priority. V0 generates a public roadmap page that shows features grouped by quarter with status badges. The product team updates the Notion database and the roadmap refreshes automatically.

Prompt example:

```
Create a product roadmap page that fetches items from /api/notion/roadmap. Each item has name, status ('Planned', 'In Progress', 'Shipped'), quarter ('Q1 2026'), team, and priority. Group items by quarter in chronological order. Within each quarter, show items as cards with the feature name, a status badge (grey=Planned, blue=In Progress, green=Shipped), team label, and priority indicator. Add a status filter above to show All / Planned / In Progress / Shipped.
```

### FAQ Page from Notion Database

A customer success team maintains FAQ content in a Notion database with Question and Answer columns. V0 generates an accordion FAQ page that renders the questions and answers. The team can add, edit, and reorder FAQ items in Notion and the website reflects changes immediately.

Prompt example:

```
Build an FAQ accordion page that fetches from /api/notion/faq. Each item has question (string) and answer (string). Display as an accordion list where clicking a question expands its answer. Use smooth animation on expand/collapse. Group by category if a category field is present. Show a search input above that filters questions client-side. Handle empty state with a 'No matching questions' message.
```

## Troubleshooting

### API returns 404 with 'Could not find database with ID'

Cause: The database has not been shared with your Notion integration. Even with a valid token and correct database ID, the API cannot access a database that has not been explicitly connected to the integration.

Solution: Open the Notion database, click the '...' menu, select 'Add connections', and add your integration. Also verify the NOTION_DATABASE_ID value — it should be a 32-character string from the database URL, formatted with or without hyphens.

```
// Database ID can be formatted either way:
// With hyphens: '12345678-1234-1234-1234-123456789012'
// Without: '12345678123412341234123456789012'
// Both work with the Notion SDK
```

### Properties return undefined or empty strings for all fields

Cause: Notion property names in the API are case-sensitive and must exactly match your database column names. If your column is named 'title' (lowercase) and your code accesses props['Title'] (uppercase T), it returns undefined.

Solution: Console.log the raw props object from the Notion API response to see the exact property keys. Update your helper function calls to use the exact column names from your Notion database. A quick way to check: log Object.keys(pages[0].properties) in your API route.

```
// Debug: log actual property names from Notion
console.log('Available properties:', Object.keys(pages[0]?.properties || {}));
// Then update property access to match exactly:
// props['My Title'] not props['title']
```

### Uploaded images in Notion stop displaying after an hour

Cause: Notion's signed S3 URLs for uploaded files expire after 1 hour. If your API route caches the response, the image URLs in the cache become invalid as Notion rotates the signed URLs.

Solution: Either reduce your cache TTL to under 1 hour (next: { revalidate: 1800 } or less), or better yet, use external image URLs in your Notion database — paste links from Cloudinary, Imgur, or your own CDN instead of uploading files directly to Notion. External URLs do not expire.

```
// Option 1: Shorter cache TTL
next: { revalidate: 1800 } // 30 minutes

// Option 2: Use external image URLs in Notion (recommended)
// In your Notion database, use a URL property for images
// and paste CDN links rather than uploading files directly
```

## Frequently asked questions

### Does using Notion as a CMS require any Notion paid plan?

The Notion API is available on all plans including the free plan. You can create integrations and query databases on a free Notion account. Paid plans add features like team collaboration, advanced permissions, and more blocks per page, but API access itself is not gated by plan level.

### How do I display Notion page content (body text) rather than just database properties?

Use notion.blocks.children.list({ block_id: pageId }) to fetch the page body as an array of block objects. Each block represents a content element like a paragraph, heading, or image. You can convert these to Markdown with a helper function or use the react-notion-x library for richer rendering with support for all Notion block types including callouts, toggles, and embeds.

### How do I update content in Notion from my V0 app?

The Notion API supports PATCH requests for updating page properties. Use notion.pages.update({ page_id, properties: { ... } }) to update database fields. For creating new pages (rows), use notion.pages.create({ parent: { database_id }, properties: { ... } }). Both require the 'Update content' and 'Insert content' capabilities to be enabled on your integration.

### How do I handle Notion databases with many pages for performance?

The Notion API returns up to 100 results per request. For larger databases, use the start_cursor and has_more fields to paginate through results. For public-facing websites with many posts, implement Next.js ISR (Incremental Static Regeneration) so each post page is statically built and served from CDN rather than fetched on every request.

### Can I use Notion as a CMS for multiple content types (blog + FAQ + roadmap)?

Yes. Create separate Notion databases for each content type and add separate environment variables for each database ID (NOTION_BLOG_DATABASE_ID, NOTION_FAQ_DATABASE_ID, etc.). Create separate API routes for each content type with type-specific property mapping. Each database can have a different schema matching its content structure.

### How do I search across Notion databases from my V0 app?

The Notion API has a search endpoint (notion.search()) that searches across all databases and pages accessible to your integration. For database-specific search, use the filter parameter in databases.query() — for example, filtering Title rich text that contains your search term. For better full-text search, consider syncing Notion content to Algolia or a vector database for more powerful search functionality.

---

Source: https://www.rapidevelopers.com/v0-integrations/notion
© RapidDev — https://www.rapidevelopers.com/v0-integrations/notion
