npm.io
0.0.14 • Published 4d ago

@prkedia81/notion-blogs

Licence
ISC
Version
0.0.14
Deps
4
Size
168 kB
Vulns
0
Weekly
24

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


Built for people who'd rather write in Notion than wrestle a CMS.

Keywords