npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@prkedia81/notion-blogs

v0.0.15

Published

Use Notion as a headless CMS for your blog — fetch posts, authors, and tags as typed data and render Notion blocks to clean, SEO-friendly HTML. Works with Next.js, React, and any Node.js app.

Readme

📝 notion-blogs

Turn a Notion database into a production-ready, SEO-friendly blog — with zero CMS to host and zero markdown to write.

npm version npm downloads bundle size types license

@prkedia81/notion-blogs lets you write blog posts in Notion — the editor you already love — and pull them into any React, Next.js, or Node.js app as clean, structured data and ready-to-render HTML. Authors, tags, cover images, SEO descriptions, "similar posts", and rich content blocks all come parsed and typed out of the box.

import { NotionBlog } from '@prkedia81/notion-blogs';

const blog = new NotionBlog();              // reads NOTION_KEY + NOTION_BLOG_ID from env
const posts = await blog.getAllPosts();     // fully-typed BlogPost[]
const post  = await blog.getBlogBySlug('hello-world'); // includes rendered HTML

Table of contents


Why notion-blogs?

Most "Notion as a CMS" solutions hand you back the raw, deeply-nested Notion API response and leave the hard parts to you. This package does the heavy lifting:

| You get | Instead of | | --- | --- | | A clean BlogPost object with title, slug, author[], tags[], coverImage[], seoDescription… | Hundreds of lines of nested properties.title.title[0].plain_text access | | Rendered, semantic HTML for the post body | A flat list of block objects you have to walk yourself | | Resolved author and tag relations | Bare relation IDs you'd have to fetch and join manually | | Built-in slug routing, "similar posts", and published/draft filtering | Custom query + filter plumbing | | First-class TypeScript types for everything | any everywhere | | Automatic pagination and rate-limit retries (429 backoff) | Manual cursor loops and retry logic |

Write in Notion. Ship a blog. That's it.


Features

  • ✍️ Author in Notion — your writers never touch code or markdown.
  • 🧱 Block-to-HTML parser — paragraphs, headings, images, callouts, columns, tables, nested lists, to-dos, video (incl. YouTube embeds), files, dividers and rich-text annotations (bold, italic, code, color, links).
  • 🔗 Relations resolved — authors and tags are joined into each post automatically.
  • 🧭 Routing-ready — fetch by numeric ID or by URL slug.
  • 🧩 "Similar posts" — surface related content by shared author/tag with one flag.
  • 🚦 Drafts vs. published — only isPublished posts are returned by listings.
  • 🔁 Resilient — exponential-backoff retries on Notion rate limits and full pagination of long posts.
  • 🟦 TypeScript-first — ships its own .d.ts; zero @types needed.
  • ⚛️ Framework-agnostic — works in Next.js (App & Pages Router), Remix, Astro, Express, or any Node runtime.

Installation

npm install @prkedia81/notion-blogs
# or
yarn add @prkedia81/notion-blogs
# or
pnpm add @prkedia81/notion-blogs

React is an optional peer dependency — you only need it if you use the bundled UI components. The core data/HTML APIs are pure Node and run anywhere.

# only if you use the React components
npm install react react-dom

Notion setup (5 minutes)

1. Create a Notion integration

  1. Go to notion.so/my-integrationsNew integration.
  2. Give it a name (e.g. My Blog) and pick the workspace.
  3. Copy the Internal Integration Secret — this is your NOTION_KEY.

2. Create your databases

You need one blog database (required) and, optionally, author and tag databases. Create the properties below — names are case-sensitive and must match exactly (note the space in AI summary).

Blog database (required)

| Property | Notion type | Required | Notes | | --- | --- | :---: | --- | | title | Title | ✅ | Post title. | | isPublished | Checkbox | ✅ | Only true posts appear in listings. | | ID | Unique ID | ✅ | Auto-incrementing; used by getBlogById. | | slug | Text | – | URL slug. Falls back to a slugified title if empty. | | author | Relation → Author DB | – | One or more authors. | | tags | Relation → Tag DB | – | One or more tags. | | publishedDate | Date | – | Display/publish date. | | coverImage | Files & media | – | Hero image(s). | | seoDescription | Text | – | Meta description for <head>. | | AI summary | Text | – | Optional short summary (note the space). | | featured | Checkbox | – | Flag for highlighting a post. | | createdDate | Created time | – | Auto-managed by Notion. | | lastEditedDate | Last edited time | – | Auto-managed by Notion. |

The body of each Notion page becomes the post content and is parsed to HTML when you fetch a single post.

Author database (optional)

| Property | Notion type | Notes | | --- | --- | --- | | name | Title | Author name. | | slug | Formula → string | e.g. lower(replaceAll(prop("name"), " ", "-")). Used by getPostsByAuthor. | | authorBio | Text | Short bio. | | authorImage | Files & media | Avatar(s). | | ID | Unique ID | | | createdDate | Created time | | | lastEditedDate | Last edited time | |

Tag database (optional)

| Property | Notion type | Notes | | --- | --- | --- | | name | Title | Tag name. | | slug | Formula → string | Used by getPostsByTag. | | ID | Unique ID | | | createdDate | Created time | | | lastEditedDate | Last edited time | |

💡 If you don't configure an author or tag database, those relations simply resolve to empty arrays — the blog still works.

3. Share the databases with your integration

Open each database in Notion → ••• menu → Connections → add the integration you created. Without this step the API returns nothing.

4. Grab the database IDs

The database ID is the 32-character hash in the database URL:

https://www.notion.so/workspace/8a4b1c2d3e4f5061728394a5b6c7d8e9?v=...
                                └──────────── database ID ────────────┘

Quick start

The fastest path is environment variables. Create a .env:

NOTION_KEY=secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
NOTION_BLOG_ID=8a4b1c2d3e4f5061728394a5b6c7d8e9
NOTION_TAG_ID=...      # optional
NOTION_AUTHOR_ID=...   # optional

Then:

import { NotionBlog } from '@prkedia81/notion-blogs';

const blog = new NotionBlog();

// List every published post (no body content, ideal for index pages)
const posts = await blog.getAllPosts();

// Fetch one post by slug — includes rendered HTML in `post.content`
const post = await blog.getBlogBySlug('my-first-post');
console.log(post?.title);
console.log(post?.content); // -> "<h1>…</h1><p>…</p>"

Prefer explicit config over env vars? Pass it to the constructor:

const blog = new NotionBlog({
  notionKey: process.env.NOTION_KEY,
  blogDatabaseId: process.env.NOTION_BLOG_ID,
  authorDatabaseId: process.env.NOTION_AUTHOR_ID, // optional
  tagDatabaseId: process.env.NOTION_TAG_ID,       // optional
});

Configuration

Configuration is resolved from the constructor first, then falls back to environment variables. Credentials are cached on a singleton client, so initialize once and reuse.

| Constructor option | Environment variable | Required | Description | | --- | --- | :---: | --- | | notionKey | NOTION_KEY | ✅ | Notion integration secret. | | blogDatabaseId | NOTION_BLOG_ID | ✅ | Blog database ID. | | authorDatabaseId | NOTION_AUTHOR_ID | – | Author database ID. | | tagDatabaseId | NOTION_TAG_ID | – | Tag database ID. |

Setting NODE_ENV=development enables verbose [notion-blogs]: debug logging.


API reference

class NotionBlog

The recommended entry point. Construct it once and call its async methods.

const blog = new NotionBlog(config?);

| Method | Returns | Description | | --- | --- | --- | | getAllPosts() | Promise<BlogPost[]> | All published posts with authors and tags resolved. Body content is not included (fast — perfect for index/listing pages). | | getBlogById(id, includeSimilarBlogs?) | Promise<BlogPost \| null> | Fetch a single post by its numeric ID. Includes rendered HTML in content. Returns null if not found. | | getBlogBySlug(slug, includeSimilarBlogs?) | Promise<BlogPost \| null> | Fetch a single post by its slug. Includes rendered HTML in content. Returns null if not found. | | getAllAuthors() | Promise<Authors[]> | Every author. Returns [] if no author DB is configured. | | getPostsByAuthor(authorSlug) | Promise<{ author: Authors \| null; filteredBlogs: BlogPost[] }> | The author plus all their published posts. | | getAllTags() | Promise<Tags[]> | Every tag. Returns [] if no tag DB is configured. | | getPostsByTag(tagSlug) | Promise<BlogPost[]> | All published posts carrying that tag. |

Pass includeSimilarBlogs = true to populate post.similarPosts with up to 5 related posts (matched by shared author or tag, falling back to other recent posts).

const post = await blog.getBlogBySlug('scaling-notion', true);
post?.similarPosts?.forEach((p) => console.log(p.title));

Standalone functions

If you'd rather manage the Notion Client yourself (e.g. for DI or testing), every operation is exported as a standalone function that takes a Client as its first argument:

import {
  getClient,
  getAllPosts,
  getBlogById,
  getBlogBySlug,
  getPageById,
  getAllAuthors,
  getPostsByAuthor,
  getAllTags,
  getPostsByTag,
} from '@prkedia81/notion-blogs';

const client = getClient(); // singleton @notionhq/client, configured from env
const posts = await getAllPosts(client);

parseBlocksToHTML(blocks)

Convert an array of Notion block objects into a semantic HTML string. Useful if you fetch blocks yourself but still want this package's renderer.

import { parseBlocksToHTML } from '@prkedia81/notion-blogs';

const html = parseBlocksToHTML(blocks); // -> string of HTML

There's also a formatDate(isoString) helper that returns a friendly date (e.g. "June 25, 2026").

import { formatDate } from '@prkedia81/notion-blogs';
formatDate(post.publishedDate); // "June 25, 2026"

Data types

Everything is fully typed. The core shapes:

interface BlogPost {
  ID: number;
  title: string;
  slug: string;
  content?: string;          // rendered HTML (single-post fetches only)
  author: Authors[];
  tags: Tags[];
  coverImage: string[];
  seoDescription: string;
  'AI summary': string;
  featured: boolean;
  isPublished: boolean;
  publishedDate: string;     // ISO date
  createdDate: string;       // ISO date
  lastEditedDate: string;    // ISO date
  similarPosts?: BlogPost[]; // when includeSimilarBlogs = true
}

interface Authors {
  uuid: string;
  id: number;
  name: string;
  slug: string;
  image: string[];
  bio: string;
  createdDate: string;
  lastEditedDate: string;
}

interface Tags {
  uuid: string;
  id: number;
  name: string;
  slug: string;
  createdDate: string;
  lastEditedDate: string;
}

The lower-level Block, RichText, and per-block-type interfaces are exported too, for anyone building a custom renderer.


Rendering & styling the HTML

getBlogById / getBlogBySlug return ready-to-render HTML in content. Drop it in with dangerouslySetInnerHTML (or your framework's equivalent):

export default function Post({ post }: { post: BlogPost }) {
  return (
    <article className="notion-content">
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content ?? '' }} />
    </article>
  );
}

The generated HTML uses stable, predictable class names so you can style it however you like. Key hooks:

| Class | Applied to | | --- | --- | | .notion-heading | <h1>/<h2>/<h3> | | .notion-image | image <figure> | | .notion-callout | callout box | | .notion-column-list / .notion-column | multi-column layouts | | .notion-table / .notion-table-container | tables | | .notion-video / .notion-video-container | video & YouTube embeds | | .notion-file | file attachments | | .notion-todo-item (.checked) | to-do items | | .notion-hr | dividers | | .color-<name> | Notion text/background colors |

Style these classes in your own CSS, or scope a .prose / Tailwind Typography wrapper around the rendered content for instant, attractive defaults.


Supported Notion blocks

| ✅ Supported | Notes | | --- | --- | | Paragraph | with bold, italic, strikethrough, underline, inline code, color, links | | Heading 1 / 2 / 3 | | | Bulleted list | nested children supported | | Numbered list | nested children supported | | To-do | rendered with a disabled checkbox reflecting checked state | | Image | external & uploaded, with captions, lazy-loaded | | Callout | emoji icon + color | | Columns | multi-column layouts (column_list / column) | | Table | header rows, multi-column | | Video | direct files and YouTube → responsive iframe embed | | File | download link with filename | | Divider | |

Unsupported block types are skipped gracefully rather than throwing.


Framework recipes

Next.js (App Router)

// app/blog/[slug]/page.tsx
import { NotionBlog } from '@prkedia81/notion-blogs';

const blog = new NotionBlog();

export async function generateStaticParams() {
  const posts = await blog.getAllPosts();
  return posts.map((p) => ({ slug: p.slug }));
}

export async function generateMetadata({ params }: { params: { slug: string } }) {
  const post = await blog.getBlogBySlug(params.slug);
  return { title: post?.title, description: post?.seoDescription };
}

export default async function BlogPostPage({ params }: { params: { slug: string } }) {
  const post = await blog.getBlogBySlug(params.slug, true);
  if (!post) return <p>Not found</p>;

  return (
    <article>
      <h1>{post.title}</h1>
      {post.coverImage[0] && <img src={post.coverImage[0]} alt={post.title} />}
      <div dangerouslySetInnerHTML={{ __html: post.content ?? '' }} />
    </article>
  );
}

Next.js (Pages Router)

// pages/blog/[slug].tsx
import type { GetStaticPaths, GetStaticProps } from 'next';
import { NotionBlog, type BlogPost } from '@prkedia81/notion-blogs';

const blog = new NotionBlog();

export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await blog.getAllPosts();
  return { paths: posts.map((p) => ({ params: { slug: p.slug } })), fallback: 'blocking' };
};

export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await blog.getBlogBySlug(params!.slug as string);
  if (!post) return { notFound: true };
  return { props: { post }, revalidate: 60 };
};

export default function Post({ post }: { post: BlogPost }) {
  return <div dangerouslySetInnerHTML={{ __html: post.content ?? '' }} />;
}

Express / Node API

import express from 'express';
import { NotionBlog } from '@prkedia81/notion-blogs';

const app = express();
const blog = new NotionBlog();

app.get('/api/posts', async (_req, res) => {
  res.json(await blog.getAllPosts());
});

app.get('/api/posts/:slug', async (req, res) => {
  const post = await blog.getBlogBySlug(req.params.slug);
  post ? res.json(post) : res.status(404).json({ error: 'Not found' });
});

app.listen(3000);

Error handling & rate limits

  • Rate limits (HTTP 429): requests are automatically retried with exponential backoff (3 attempts, doubling delay) — you don't need to handle 429s yourself.
  • Pagination: long posts are fully paginated; you always get the complete body, no matter the length.
  • Missing posts: getBlogById / getBlogBySlug return null (and log a warning) rather than throwing, so guarding with if (!post) is enough.
  • Missing config: constructing without a key (and no NOTION_KEY) throws a clear, actionable error.
const post = await blog.getBlogBySlug('does-not-exist');
if (!post) {
  // render your 404
}

FAQ

Do I need the author and tag databases? No. They're optional. Without them, author and tags resolve to empty arrays.

Why is content empty on getAllPosts()? By design — listing pages don't need full bodies, so listings skip the (expensive) block fetch. Use getBlogBySlug / getBlogById for the rendered HTML.

Are drafts exposed? No. Only posts with isPublished = true are returned by the listing methods.

Is it server-side only? Treat it as server-side. Your NOTION_KEY must never reach the browser — call these methods in server components, route handlers, getStaticProps, or your backend.

Does it work with TypeScript? Yes — types ship with the package, no @types/... needed.


Roadmap

  • [ ] Richer, themeable React components (cards, lists, full post renderer)
  • [ ] Pluggable custom block renderers
  • [ ] Code blocks with syntax highlighting
  • [ ] Audio & toggle block support
  • [ ] Built-in flex/grid layout presets
  • [ ] Incremental cache / revalidation helpers

Have a request? Open an issue.


Contributing

Contributions, issues, and feature requests are welcome!

git clone https://github.com/prkedia81/notion-blog-package.git
cd notion-blog-package
npm install
npm run build   # compile TS + build CSS
npm test

Please run npm run lint and npm run format before opening a PR.


License

ISC © Prannay Kedia