Guidance for coding agents working in this repository. Read this first; it captures things you cannot derive by reading the code alone.
notion-as-a-cms is a Next.js 16 (App Router, Turbopack) site that uses a Notion database as its CMS. Pages are not authored in code — they live as rows in a Notion database, fetched at request time via the official @notionhq/client, and rendered to React using a small block-to-component dispatcher.
Stack: Next.js 16 · React 19 · TypeScript (strict) · Tailwind CSS 3 · @notionhq/client v5 (Notion API 2025-09-03).
This repo follows the 30 principles in PRINCIPLES.md (Kent Beck's Smalltalk Best Practice Patterns, generalized). Read them once; apply them as refactoring targets when you notice friction, not as a checklist.
The ones that come up most often in this codebase:
- #2 Intention-revealing names —
getPageBySlug,renderRichText,getIconStringdescribe purpose, not mechanism. Match this when adding helpers. - #3 Replace comments with clear code — there are essentially no comments in
src/. Don't introduce explanatory ones; extract a function instead. - #5 Single responsibility — atoms render one block type;
lib/notion/*files each own one concern (client / database / page / richText). Don't merge them. - #6 Say things once — block-type dispatch lives only in
NotionRenderer. Don't create a parallel switch elsewhere. - #9 Guard clauses —
getPageBlocks,renderMention,renderRichText,getIconStringall bail early on missing/invalid input. Keep the main path flat. - #13 Polymorphism over conditionals — if
NotionRenderer's switch grows past a handful more cases, consider aBLOCK_RENDERERSmap keyed byblock.typebefore piling on cases. - #19 Named constants —
homeSlug,defaultWidth,ratio,defaultHeightalready live insrc/app/constants.ts. Add new magic values there.
npm run dev # next dev (Turbopack is the default in Next 16)
npm run build # next build
npm run start # next start (after build)
npm run lint # eslint . (ESLint flat config, next/core-web-vitals + next/typescript)There is no test suite. Validation = npm run lint + npm run build + manual verification in the browser against a real Notion DB.
Required in .env (see .env.example):
NOTION_SECRET— Internal integration token from Notion.NOTION_DATABASE_ID— ID of the CMS database. Each row is a page.NOTION_IMAGE_HOSTNAME— Hostname fornext/imageremotePatterns(defaults toprod-files-secure.s3.us-west-2.amazonaws.com). Used innext.config.ts:8.
Optional (theming — see "Styling system"):
SITE_THEME— Style theme.notion(default) orminimal. Unset/unknown falls back tonotion.
Without NOTION_SECRET / NOTION_DATABASE_ID, every page renders 404 because the database query throws.
This is the single most important flow in the codebase. Trace it before changing anything in src/lib/notion/ or the renderer.
- Request hits
src/app/[slug]/page.tsx(orsrc/app/page.tsxfor/, which delegates toDynamicPagewithslug = homeSlug = "home"fromsrc/app/constants.ts:2). getPageBlocks(slug)is called (src/lib/notion/page.ts:80):getNotionClient()creates aClientwithNOTION_SECRET(src/lib/notion/client.ts).getDatabase(client)callsclient.databases.retrieve({ database_id })to resolve the database's first data source, then runsclient.dataSources.query({ data_source_id })against it (src/lib/notion/database.ts). Returns the full data-source query result — no pagination, no filter, no sort. Two API calls per request.getPageBySluglinearly scansdatabase.resultsand finds the row whoseSlugproperty equals the requested slug (string compare).client.blocks.children.list({ block_id: page.id })fetches top-level blocks. No recursion into nested children.
- If
blocksisnull, the route callsnotFound()→ renderssrc/app/not-found.tsx→NotFoundPageorganism. - Otherwise blocks are handed to
NotionRenderer(src/app/components/molecules/NotionRenderer.tsx), which switches onblock.typeand dispatches to an atom component. - Each atom calls
renderRichText(block.X.rich_text)fromsrc/lib/notion/richText.tsxto turn Notion rich-text spans into React. generateMetadataseparately callsgetPageMeta(slug)(src/lib/notion/page.ts:57), which reads the page properties (Name,Slug,Meta title,Meta description,SEO keywords,icon) and feeds them togetMetadata(src/lib/metadata/index.ts).
The Notion DB schema the code assumes:
| Property | Type | Used as |
|---|---|---|
Name |
title | Fallback for <title> when Meta title is empty |
Slug |
rich_text | URL segment; home is the index route |
Meta title |
rich_text | <title> |
Meta description |
rich_text | <meta name="description"> |
SEO keywords |
rich_text | <meta name="keywords"> |
| page icon | emoji/file/external | favicon (<link rel="icon">) |
Renaming or removing any of these in Notion breaks pages — getPropertyValue throws on non-text properties (src/lib/notion/page.ts:30).
- Atomic Design under
src/app/components/:atoms/,molecules/,organisms/. Files arePascalCase.tsx, one component per file, named exports preferred (some useexport defaultfor Next route conventions only). - Path alias:
@/*→./src/*(tsconfig.json:22). Use@/lib/notion,@/app/constants, etc. in component imports. Relative imports are fine within the same subtree. - Re-exports:
src/lib/notion/index.tsis the barrel forlib/notion. Add new public surface area there. - TypeScript:
strict: true. Don't addany; the Notion SDK exports discriminated unions — narrow withif (block.type === "...")rather than casting. - Server components by default: every component here runs on the server. Don't add
"use client"unless you genuinely need browser APIs — Notion data fetching must stay server-side (the secret would leak). - No tests, no CI config in repo. Don't add testing scaffolding unless explicitly asked.
- Changesets:
.changeset/is present and the project versions via Changesets. When you make a user-visible change, ask whether to add a changeset (npx changeset) rather than guessing.
- Tailwind 4, configured entirely in CSS. There is no
tailwind.config.ts.src/app/globals.cssis now thin:@import "tailwindcss", the@themecolor map, the@source inline(...)directive (keepsrenderRichText's dynamictext-notion-*/bg-notion-*class names alive), and@imports of the theme CSS files. Color utilities liketext-notion-blueare declared as--color-*in@theme— extend that pattern rather than adding a config file. - Themes live in
src/themes/, one self-contained directory per theme:src/themes/index.ts— selection:themeNamesandgetActiveThemeName()(resolvesSITE_THEME, defaultnotion, falls back safely).src/themes/<name>/<name>.css— the theme's styles.notion/notion.cssis the base: it defines the Notion color tokens (--notion-*, light:root+ dark[data-theme="dark"]) and the bare element styles (body,.content,h1-h3,p,li,a,.code,.equation). Other themes (e.g.minimal/minimal.css) are a[data-site-theme="<name>"]block overriding tokens/fonts/elements.globals.css@imports them notion-first, so later themes win at equal specificity. Notion content color annotations on a span still override a theme's element color — that's intended.src/themes/<name>/fonts.ts— the theme'snext/fontloaders, each exposing a CSS variable.src/themes/fonts.tsaggregates them intofontVariables, whichlayout.tsxapplies to<html>(alongsidedata-site-theme). A font is only downloaded when the active theme's CSS references its variable; non-default fonts usepreload: false. The semantic--font-body/--font-displayvars are what theme CSS points at the loaded fonts.
- Global element styles live in
notion/notion.css(the base), not per-component — atoms render bare elements (<h1>{...}</h1>) and let theme CSS style them. Don't add per-component typography classes that fight this; theme-specific overrides go in that theme's[data-site-theme=...]block.
- No pagination.
getDatabasereturns the first page of results (≤100 rows) —client.dataSources.queryis not iterated. Same forblocks.children.list. A database with >100 published pages will silently drop entries. - First data source only.
getDatabasepicksdatabase.data_sources[0]. Multi-data-source databases (Notion API ≥2025-09-03) will silently ignore the rest. - No caching. Each request triggers a full DB query plus a blocks fetch. There is no
unstable_cache,revalidate, or ISR config — every nav is a live Notion API call. If you're asked to add caching, this is where. - No nested blocks.
getPageBlockslists one level. Toggle children, nested list items, callout bodies, column children, etc. won't render even if you add the atom. richTextToPlainTextjoins spans with" "(src/lib/notion/richText.tsx:11-13). That means**Hello**, worldround-trips with a stray space. It's used for property values and image captions — fine for SEO meta, surprising elsewhere.- Mentions only support
pagementions. Database, user, date, link-preview, and template mentions log a warning and render nothing (src/lib/notion/richText.tsx:28-56). A mention also incurs an extrapages.retrieveAPI call per mention per render. - Images: fixed
defaultWidth = 500,ratio = 16/9(src/app/constants.ts). Notion's actual image dimensions are not read.NOTION_IMAGE_HOSTNAMEmust match the host of the URL Notion returns ornext/imagewill reject it. - External link target: all external links open with
target="_blank"but norel="noopener noreferrer"(src/lib/notion/richText.tsx:66). If you touch that file, fix this. - Slug matching is exact and case-sensitive. Two pages with the same slug → the first one in DB order wins.
- For UI / rendering changes: run
npm run dev, open a Notion page that exercises the change, and visually verify before reporting done. There is no other way to validate. - Don't introduce dependencies casually — the package set is intentionally minimal (Notion SDK + Next + React). Justify additions.
- Don't commit
.env. It is gitignored. - Don't generate URLs that aren't already in the repo or user message; the demo URL and Notion template URL in
README.mdare the canonical references.
- Schema changes (renaming Notion DB columns, repurposing properties) — affects every deployment of this boilerplate.
- Adding caching/revalidation — has correctness vs. freshness trade-offs the user should pick.
- Adding client-side interactivity — moves data fetching surface around and risks leaking
NOTION_SECRETif done wrong. - Adding a new theme — create
src/themes/<name>/with<name>.css(a[data-site-theme="<name>"]block) andfonts.ts(anynext/fontloaders), register the name insrc/themes/index.ts(themeNames),@importthe CSS inglobals.cssafternotion, and add the theme's fonts to the aggregator insrc/themes/fonts.ts. Themes that need a different page structure (header/footer chrome, multi-column) rather than just restyled blocks will need a per-theme shell component — that's a bigger change worth confirming first. - Removing the hard-coded
data-theme="dark"— the light-mode tokens exist but aren't wired up; the user may or may not want light mode.