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

notion-to-site

v1.5.0

Published

Sync any Notion database to static markdown, MDX, or JSON files

Downloads

201

Readme

notion-to-site

Turn any Notion database into typed content for your site.

  • Typed content model. nts introspects your database and generates TypeScript types from its real schema — your content, your shape, fully typed.
  • One command sync. Point nts at a Notion database and get structured markdown, MDX, or JSON with full frontmatter.
  • All block types. Paragraphs, headings, images, callouts, columns, tables, equations, toggles, synced blocks, bookmarks, video, audio, file, PDF, embeds — with rich-text fidelity (annotations, colors, mentions).
  • Framework integrations. First-class loaders for Astro (notion-to-site/astro) and Next.js (notion-to-site/next).
  • Incremental updates. Track what changed since the last sync. Only re-fetch updated pages.

Install

npm install -g notion-to-site

Quick start

1. Create a Notion integration

Go to notion.so/my-integrations and create an internal integration. Copy the API key.

2. Share your database

Open your Notion database, click the ... menu, then "Connections", and add your integration.

3. Create a config file

nts init

This creates nts.config.mjs in your project. Open it and set your database ID:

// nts.config.mjs
export default {
  database: 'your-database-id-here',
  output: './content',
  adapter: 'markdown',
  author: 'Your Name',
  linkPrefix: '/blog',
  images: { download: true, outputDir: './public/images', format: 'webp', quality: 80 },
  schema: { strict: false },
  sync: { concurrency: 5, deletions: true },
  content: { toc: false, stripBackLinks: true },
}

4. Set your API key and sync

export NOTION_API_KEY=ntn_your_key_here
nts sync

Your content files will appear in ./content.

Get a blog database in one command

Don't have a database yet? nts template create builds a blog-shaped Notion database with the right properties (Title, Slug, Status, Category, Tags, Language, Author, SEO Title, Description, Date, Cover) and seeds a few sample posts. Set NOTION_API_KEY, share a Notion page with your integration, then:

nts template create --parent <pageId>
# ✓ Database ready
#   Database ID:  24f10a3b-…-9c2e   ← put this in nts.config.mjs / NOTION_DATABASE_ID

Add posts to an existing database any time (idempotent — it won't duplicate):

nts template seed --db <databaseId>

Prefer clicking? Duplicate the public Notion template, share the copy with your integration, and paste its id into your config. Either path lands you on the same schema.

Config reference

| Field | Type | Default | Description | |---|---|---|---| | database | string | (required) | Your Notion database ID | | dataSource | string | auto | Explicit data source ID, for a database with more than one | | output | string | './content' | Directory for output files | | adapter | 'markdown' \| 'mdx' \| 'json' | 'markdown' | Output format | | author | string | '' | Default author name when not set on the page | | linkPrefix | string | '/blog' | URL prefix for internal Notion links | | images.download | boolean | true | Download images locally | | images.outputDir | string | './public/images' | Where to save downloaded images | | images.format | 'webp' \| 'original' | 'webp' | Convert images to WebP or keep original format | | images.quality | number | 80 | WebP compression quality (1-100) | | images.placeholder | boolean | false | Generate a base64 blur placeholder for each image — consumed by <NotionImage> | | images.sizes | number[] | none | Widths to emit as additional WebP variants for responsive srcset | | schema.strict | boolean | false | Validate frontmatter with Zod on every sync | | schema.mode | 'typed' \| 'legacy' | 'typed' | Frontmatter shape — see Schema modes | | schema.typesOutput | string | './.notion-to-site/types.ts' | Where typed mode writes the generated schema | | sync.concurrency | number | 5 | Number of pages to sync in parallel | | sync.deletions | boolean | true | Delete local files for pages removed from Notion | | content.toc | boolean | false | Insert a table of contents after the first heading | | content.stripBackLinks | boolean | true | Remove back-navigation links from Notion pages | | content.color | 'drop' \| 'inline' \| 'class' | 'drop' | How to render Notion text/background colors | | content.transformers | Record<string, fn> | none | Per-block-type rendering overrides | | watch.interval | number | 60 | Polling interval in seconds for nts watch | | query.filter | NtxQueryFilter | none | Sync only pages matching a Notion filter (see below) | | query.page_size | number | 100 | Page size for the database query (max 100) |

Schema modes

schema.mode controls the shape of the frontmatter nts sync writes.

  • 'typed' (default)nts introspects your Notion database, generates a TypeScript module (./.notion-to-site/types.ts: a Zod schema plus its inferred type), and writes a flat frontmatter that mirrors your database's real property names, plus _id (the slug) and _notion_id. This is what makes the "X" in notion-to-X your own content model rather than a fixed blog shape.
  • 'legacy' — the pre-1.0 nested shape, with page metadata under meta.* (meta.title, meta.tags, …). Set this to keep output unchanged from 0.x.

Upgrading from 0.x? Run nts migrate — it checks whether your config pins schema.mode and tells you exactly what changes.

Filtering at sync time

To sync only a subset of a database, pass a Notion-shaped filter object. This forwards as-is to Notion's databases.query endpoint, so the full filter syntax is supported.

// nts.config.mjs — sync only pages tagged "tudatsu" on a "Domain Tags" multi-select
export default {
  database: 'YOUR_DB_ID',
  output: './content',
  adapter: 'markdown',
  images: { /* ... */ },
  schema: { /* ... */ },
  query: {
    filter: {
      property: 'Domain Tags',
      multi_select: { contains: 'tudatsu' },
    },
  },
}

You can compose filters with and / or:

query: {
  filter: {
    and: [
      { property: 'Status', select: { equals: 'Published' } },
      { property: 'Domain Tags', multi_select: { contains: 'tudatsu' } },
    ],
  },
}

Domain Tags

If your database has a Domain Tags multi-select column, its values are written to meta.domain_tags on every synced page — useful when one Notion database feeds multiple sites and you want render-time per-page routing in addition to (or instead of) sync-time filtering.

CLI commands

| Command | Description | |---|---| | nts init | Create nts.config.mjs in the current directory | | nts template create --parent <pageId> | Create a blog-shaped database under a page and seed sample posts | | nts template seed --db <id> | Add sample posts to an existing database (idempotent) | | nts classify | Detect the database's kind (blog, people, projects, …) + field roles | | nts sync | Full sync of your Notion database to local files | | nts sync --incremental | Only sync pages changed since the last run | | nts sync --db <id> | Override the database ID from config | | nts types | Generate TypeScript types from your Notion database schema | | nts watch | Poll and incrementally sync on a timer | | nts watch --interval <seconds> | Set the polling interval (default: 60) | | nts validate | Validate all output files against the Zod schema | | nts status | Show sync state, tracked pages, and statistics | | nts migrate | Check your config against the 1.0 schema-mode default change |

Output format

Each synced page produces a file with YAML frontmatter and rendered content. The sample below is the legacy schema-mode shape; in the default typed mode the frontmatter is flat and mirrors your database's real property names (see Schema modes).

---
id: my-first-post
path: /content/my-first-post.md
type: post
intent: ""
version: "1.0"
created: "2026-01-15"
last_updated: "2026-03-20"
source:
  platform: notion
  page_id: abc12345-def6-7890-abcd-ef1234567890
meta:
  title: My First Post
  seo_title: My First Post
  author: Your Name
  description: A short summary extracted from the first paragraph.
  canonical: ""
  category:
    - Engineering
  main_tag: Tutorial
  tags:
    - tutorial
    - getting-started
  featured: false
  featured_at: []
  language: en
  post_type: Post
  status: Published
  reading_time: 4
  word_count: 812
  comment: ""
  cover_image: ""
  domain_tags: []
---

# My First Post

Your content starts here...

Frontmatter fields

  • id -- The slug, derived from the page title or a custom Slug property in Notion.
  • path -- Relative file path in the output directory.
  • type -- From the Type/Kind property in Notion, defaults to post.
  • created / last_updated -- Dates from Notion page metadata.
  • source.page_id -- The original Notion page ID for traceability.
  • meta.seo_title -- From an SEO Title property, or falls back to the page title.
  • meta.description -- From a Description property, or auto-extracted from the first paragraph.
  • meta.language -- From a Language property, or auto-detected from the content.
  • meta.reading_time -- Estimated minutes to read (200 words/minute).
  • meta.word_count -- Total word count of the content body.

Framework guides

Next.js

Wrap your next.config with withNotion() to sync on every build, then read content through the typed accessors and render it with the <NotionContent> server component:

// next.config.mjs
import { withNotion } from 'notion-to-site/next/plugin'
export default withNotion({})

withNotion lives in the build-time entry notion-to-site/next/plugin so the sync engine (@notionhq/client, sharp) never lands in your runtime/server bundle or Next's file trace. The runtime readers below import from notion-to-site/next. Use an ESM config (next.config.mjs): the package is ESM-only, so next.config.ts cannot resolve the subpath export.

// app/posts/[slug]/page.tsx
import { getPageBySlug, NotionContent } from 'notion-to-site/next'

export default async function Post({ params }) {
  const post = await getPageBySlug((await params).slug)
  return <NotionContent body={post.content} />
}

getAllPages() / getPageBySlug() are generic — pass your generated NotionContent type (typed mode) for full type safety. See examples/nextjs.

Astro

The cleanest path is the Content Layer loader — no nts sync step and no files on disk:

// src/content.config.ts
import { defineCollection } from 'astro:content'
import { notionLoader } from 'notion-to-site/astro'

const blog = defineCollection({
  loader: notionLoader({ database: process.env.NOTION_DATABASE_ID }),
})
export const collections = { blog }

The loader runs the sync engine in-process when Astro builds the collection, so getCollection() and <Content /> work directly. See examples/astro. Prefer files on disk? Set output to ./src/content/blog, run nts sync, and define a matching collection schema.

SvelteKit

Use mdsvex for markdown processing. Set output to a directory your routes can import from, and parse frontmatter with gray-matter in your +page.ts load function. The JSON adapter (adapter: 'json') is also a good fit if you prefer to load content as data rather than rendered markup.

How it works

nts connects to the Notion API, fetches every page in your database, and converts each one to markdown using custom block-type renderers. It extracts metadata from Notion page properties (title, tags, status, author, etc.), builds structured frontmatter, resolves internal links between pages, and writes the result to your chosen output format.

Environment variables

| Variable | Description | |---|---| | NOTION_API_KEY | Your Notion integration API key (required) |

You can set this in a .env or .env.local file in your project root. Both are loaded automatically.

License

MIT