I wanted one place to edit everything on ernerdo.com: the projects, the resume copy, the work experience and the blog posts. I did not want a CMS to host, an admin panel to secure, or a Markdown repo that turns every typo fix into a deploy. I already live in Notion, so I made it the CMS.
It works, but "Notion as a CMS" hides a few decisions that decide whether the site is pleasant or fragile. This post covers the ones I had to get right: two different Notion APIs, calls that never throw, and protecting the API from random URLs.
Two APIs for two jobs
The site uses the official Notion API for rows and properties, and an unofficial one for post bodies.
NotionService in src/lib/notion.ts wraps both:this.client = new Client({ auth: process.env.NOTION_SECRET, notionVersion: '2025-09-03', }) this.clientUnOficial = new NotionAPI({ activeUser: process.env.NOTION_ACTIVE_USER, authToken: process.env.NOTION_TOKEN_V2, })
The official API is what you want for structured data: the
Published checkbox on a project, the status and publishDate of a post, the Start and End dates of a job. It is documented, versioned and token based.It is not what you want for rendering a post. It returns blocks you have to fetch level by level and convert yourself. react-notion-x skips that: notion-client fetches a page as a
recordMap, and NotionRenderer renders it with Notion's own look. The catch, stated in the README, is that notion-client talks to Notion's private API, the one its own apps use. It needs the token_v2 cookie and user id of an account that can read the page, so it can break without notice and the token is a credential you have to guard.I accept that trade for the bodies only. If the unofficial side breaks, the lists and properties still work and only the post pages degrade.
Querying data sources
Since API version 2025-09-03, a Notion database is a container for one or more data sources, and rows are queried through the data source. The upgrade guide describes the change: operations that took a
database_id now take a data_source_id, which you read from the data_sources array on the database object.I resolve it once per database and cache it per service instance:
private async getDataSourceId(databaseId: string): Promise<string | null> { if (!databaseId) return null if (databaseId in this.dataSourceCache) return this.dataSourceCache[databaseId] try { const database = await this.client.databases.retrieve({ database_id: databaseId, }) if (!isFullDatabase(database)) { this.dataSourceCache[databaseId] = null return null } const dataSourceId = database.data_sources?.[0]?.id ?? null this.dataSourceCache[databaseId] = dataSourceId return dataSourceId } catch { this.dataSourceCache[databaseId] = null return null } }
Every query then goes through
client.dataSources.query({ data_source_id }). I take the first data source because each of my databases has exactly one. If you plan to use multi-source databases, this is the line to revisit.One property decides what the public sees. The blog query filters on
status: in production only Published rows come back, everywhere else every status does. That is the publish flow. A draft lives in the database with status Reviewing, I read it on the dev site, and it goes live when I flip it to Published. Preview deployments see everything, production sees only what I approved.Every call fails soft
The rule I follow: no Notion call may throw into a page. Every public method of
NotionService catches and returns an empty value ([], null or {}), and every getStaticProps wraps the whole thing again and returns normalized empty props. The home page does it like this:export const getStaticProps: GetStaticProps = async () => { try { const notionService = new NotionService() const projects = await notionService.getAllProjects() // ... return { props: { fourLatestProjects, /* ... */ }, revalidate: 60 } } catch (error) { console.error(error) return { props: { fourLatestProjects: [], latestPosts: [], experience: [] /* ... */ }, revalidate: 60, } } }
The reason is the build.
next build calls getStaticProps for every page, so a missing token or a Notion outage would otherwise fail the deploy. With soft failures the site always builds, and the next ISR cycle fills the content in. It also means a fresh clone with no credentials renders every page with empty data, which is handy for CI and for anyone who clones the repo.There is a cost, and I want to be honest about it. As the ISR docs describe it, when a background regeneration throws, Next.js keeps serving the previous page. My catch-all removes that safety net: if Notion fails during a revalidation,
getStaticProps returns valid but empty props and the empty page replaces the good one until the next successful cycle, at most 60 seconds later with this revalidate. For a personal site I take that deal over a failed build. For anything with real traffic I would rethrow when a page already exists and only fail soft when there is nothing to lose.TODO (author): if you have ever seen the site go empty or stale because of a Notion outage or a revoked token, add one or two sentences about when it happened and what you did. If not, say that the soft-fail rule has never been tested in production.
Not letting random URLs burn the API
Dynamic routes use
fallback: 'blocking', which means any unknown URL renders on demand. On this site that is expensive: resolving a post runs a data source query by slug, and if that finds nothing, fetchPageBySlug falls back to paging through the whole database and comparing slugs, before the unofficial client is even called. A bot walking /blog/<random> would turn into a steady stream of Notion requests.Two small guards fix it. First, slugs are validated before anything touches Notion:
// src/util/slug.ts export const SLUG_PATTERN = /^[a-zA-Z0-9]+(?:[-_.]+[a-zA-Z0-9]+)*$/ export const SLUG_MAX_LENGTH = 120 export const isValidSlug = (slug: unknown): slug is string => typeof slug === 'string' && slug.length <= SLUG_MAX_LENGTH && SLUG_PATTERN.test(slug)
Second, a 404 that is final gets a longer ISR window than a page:
// src/util/revalidate.ts export const NOT_FOUND_REVALIDATE = 300
In
src/pages/blog/[slug].tsx a malformed slug returns notFound with that 300 seconds, and so does a slug the official API does not know (official_post_missing). The other failure statuses, such as the unofficial API not returning a record map, keep revalidate: 60, because those can be transient and a post that exists should come back quickly.That distinction is why
getBlogPageAccess returns a discriminated union (ok, official_post_missing, unofficial_page_not_found, unofficial_record_map_missing) instead of a nullable post. The page needs to know why something is missing to decide how long to remember it.TODO (author): if you have numbers from Vercel or Notion on request volume before and after the slug validation (or any bot traffic you noticed), add them here. Do not estimate.
What this setup costs me
The honest list of downsides:
- The unofficial API is a liability. A cookie token expires or Notion changes its private format, and post bodies stop rendering. I once had every post turn into a 404 for a different reason on the same path, and having distinct statuses is what let me tell the cases apart.
- Content is not in git. There is no diff for a post and no review of an edit. Notion's page history is the only record.
- Signed image URLs expire, as the react-notion-x README warns, so a statically generated page can show broken images if it is not regenerated in time. The 60 second ISR window hides this for me, but it is the kind of thing you only learn by looking.
- Each page makes several Notion round trips, which is why I rely on ISR and never fetch in the browser.
TODO (author): add the real story of one failure you hit on the unofficial API (what broke, how you noticed, how long it took to fix). I only know the repo history, not the incident, so I left it out rather than invent it.
What I'd do next time
- Keep the split: official API for data, unofficial only where I need rendering, and keep the unofficial one isolated so it can fail alone.
- Fail soft at build time, but stop overwriting a good cached page with an empty one when revalidation fails.
- Validate dynamic route params before any network call, and cache final 404s longer than pages from day one, not after the first bot.
- Treat the Notion token like a production secret: its own integration, access only to the databases the site reads, kept in the host's environment variables.

