bimals.net

Notion as a headless CMS for Next.js powered portfolio hosted on Vercel

Stack
Next.jsTailwindReactVercelNotionCMSheadless
Last updatedJun 24, 2025

I wanted a personal site that worked as a portfolio as well as a place where I could post whatever I felt like. I saw a lot of setups with people writing directly on markdown files. But editing content directly on code level and having to re-deploy every time I wrote something was too much of a friction point for me.

Enter Notion. What started as my note-taking app, now powers this portfolio.

I decided to go the easier route and develop the whole thing from scratch instead of using existing setups I found online. That way I could incrementally add what I need when I need, instead of having to understand everything from the jump.

Notion Integration

The setup on Notion’s side is pretty straightforward. First thing you need is a Notion integration.

  • Create a New Integration in your Notion workspace
  • Grab the secret key
  • Share the page/database with the integration
  • Grab the id of the page/database
  • That’s it.

    Here’s the properties of my Notion database for these posts.

    zsh
    # Posts properties
    - Title (title field)
    - Description (rich text)
    - slug (rich text)
    - Status (select: Draft, Published, Archived)
    - Tags (multi-select)
    - Updated (last edited time)

    API Consumption with SDK

    Now in the frontend Next.js app, I fetch the content from Notion using it’s official SDK.

    .ts
    // Notion Integration Key
    const notion = new Client({
      auth: process.env.NOTION_KEY,
    });
    
    // Function to fetch pages of a database
    export async function fetchDatabasePages(
      databaseId: string,
      statusFilter: string = "Published"
    ): Promise<PageObjectResponse[]> {
      const response = await notion.databases.query({
        database_id: databaseId,
        filter: {
          property: "Status",
          status: {
            equals: statusFilter,
          },
        },
      });
      return response.results.filter(
        (item): item is PageObjectResponse =>
          "properties" in item && "parent" in item
      );
    }

    The function above returns the list of pages in a given database. Each page content can then be fetched directly using the page id.

    .ts
    export async function fetchNotionPageContent(
      pageId: string
    ): Promise<BlockObjectResponse[]> {
      const response = await notion.blocks.children.list({ block_id: pageId });
      return response.results as BlockObjectResponse[];
    }

    But because I’m using custom slugs for my site, I have an additional step to first fetch the page from the database using the slug property.

    .ts
     ...
     // Just core part of a bigger function
      const response = await notion.databases.query({
        database_id: dbId,
        filter: {
          property: "slug",
          rich_text: {
            equals: slug,
          },
        },
      });
      return response.results[0] as PageObjectResponse;
     ...

    Custom Renderer

    Now each blocks of a particular page are available, I created custom components for items and used a custom renderer to render the blocks on a page.

    .ts
    ...
    // A simplified version of the block renderer
    switch (block.type) {
      case "heading_1":
        return <Heading1 {...block} />;
      case "paragraph":
        return <Paragraph {...block} />;
      case "code":
        return <Code {...block} />;
      // ... and so on
    }
    ...

    Content Blocks

    Following are the custom blocks that I’ve implemented at the moment.

  • Heading 1
  • Heading 2
  • Heading 3
  • Paragraph (With rich text support)
  • Code Block
  • Page Title
  • Bulleted List Items
  • Numbered List Items
  • To-Do List Items (with checkboxes)
  • Image
  • Performance and Rate Limit

    Notion’s API has rate limits, which forces you to be smart about calling it regularly. That’s where Next.js becomes a powerful choice. Content is fetched at build time, pre-rendered and served as static HTML. As a result, users don’t have to wait for pages to complete background api calls. Combined with ISR (Incremental Static Regeneration) to periodically refresh page content without having to rebuild gives the perfect balance of fresh content and speed.

    .ts
    // Example ISR revalidation time
    export const revalidate = 300; // Refresh every 5 minutes

    Todo

    I plan to advance this project to include more custom components as I need them. Implementing webhooks to automatically fetch fresh content is also in the pipeline for the future.

    URLs

    Github: https://github.com/bimalpaudels/portfolio

    Notion: https://bimals.notion.site