From Notion to Payload: Why I Rebuilt My Portfolio's Content Engine

From Notion to Payload: Why I Rebuilt My Portfolio's Content Engine
Topics:Web DevelopmentCode MigrationFrontend Architecture
Tech:Payload CMSNotionNext.js

Every time I published an article on my portfolio, five systems had to agree with each other. The words lived in Notion. The images were generated by one automation, uploaded to Cloudinary by another, and their URLs pasted back into Notion by a third. A sync layer pulled it all into my Next.js site, translating Notion's block model into something my components could render. When any link in that chain hiccuped — an API rate limit, a malformed block, a stale webhook — publishing stopped, and debugging meant tracing a request across three vendors' dashboards.

I built that system on purpose, and for a while I defended it. This year I tore it out completely. My site now runs on Payload, a code-first CMS that lives inside the same Next.js app it serves, and the difference is not incremental: it changed what the site is, from a rendering target at the end of a pipeline into a single system I can reason about, test, and hand to an AI agent to operate safely.

This is the story of both architectures: why Notion-as-CMS was a reasonable decision when I made it, the specific ways it broke down, and the design decisions in the rebuild that I'd now carry to any content-driven project. If you're currently running content through a workspace tool — Notion, Airtable, Google Docs, a headless spreadsheet — this is a field report from the other side of that migration.

Why Notion-as-CMS felt right at the time

Let me defend my past self first, because the original decision wasn't naive.

When I built the previous version of my portfolio, I wanted three things from a content workflow: a genuinely pleasant writing environment, zero additional infrastructure to maintain, and one place where planning and writing lived together. Notion delivered all three immediately. The editor is excellent. The editorial calendar sat one page away from the drafts. The Notion API was mature enough to fetch pages and blocks reliably. The price of admission was an API token. No database to provision, no admin panel to deploy, no auth to configure.

For a solo developer shipping a portfolio, that's a seductive value proposition: use the tool you're already in, and make the website a projection of it. The site became a read-only renderer of a workspace I already lived in daily.

And it genuinely worked. I published dozens of articles through that pipeline. If your content operation is one person writing prose with modest formatting, published on no particular schedule, Notion-as-CMS is not a mistake; it's a legitimate stage-one architecture, and I'd still recommend it for that narrow case.

The problem is that content operations don't stay narrow. Mine grew images, then image automation, then SEO requirements, then the ambition to let AI agents help produce content. Each of those pushed on a wall that Notion — through no fault of its own — was never designed to be.

Where it strained

The failures weren't dramatic. They were a slow accumulation of taxes, each individually tolerable.

The block-model translation tax. Notion's block model is designed for Notion's editor, not for your design system. Every content structure I wanted on the site — code snippets with language-aware highlighting, callouts, image layouts — required translating Notion blocks into my own component vocabulary, and the translation layer had opinions about every edge case. When Notion introduced new block behaviors or my formatting drifted from what the parser expected, articles rendered wrong in ways I only discovered by looking. The CMS and the site disagreed about what the content was, and I owned the reconciliation code forever.

The media Rube Goldberg machine. Cover images were the worst of it. The flow ran: generate an image with one automation, upload to Cloudinary, write the URL back into a Notion property, wait for the sync to notice, then verify the site rendered it. Three services, four failure points, zero transactionality. A failed step didn't error loudly. It left the system in a half-state where the article existed but its image didn't, or the image existed nowhere the article could see. I built an entire job-queue apparatus in Notion databases just to track which images were in which half-state. That apparatus needed its own SOPs. The SOPs needed their own maintenance. This is the moment a clever architecture becomes a second job.

No real draft/publish semantics. Notion has no concept of "published to my website." I simulated it with status properties and filtered queries, which meant the site's definition of published was a convention, not a guarantee: one mis-set property away from a draft leaking or a live article vanishing. There was no versioning, no preview of how a draft would actually render in the site's layout, no scheduled publishing that I didn't build myself. Writing happened in one visual world and publishing happened in another, and the gap between them was where surprises lived.

No type safety across the boundary. The site consumed whatever the API returned. When content shape and code drifted apart, TypeScript couldn't warn me, because the content had no schema the compiler could see. Every content bug was a runtime bug.

Third-party coupling on the critical path. My site's content availability was downstream of another company's API uptime, rate limits, and product decisions. For a workspace tool, those constraints are reasonable. As the foundation of a website, they're a standing risk you carry silently until the day you notice how much of your system you don't control.

None of these was fatal alone. Together they meant the content pipeline was the most fragile, least testable, most convoluted part of a codebase that was otherwise modern and disciplined. The realization that finally moved me: I was spending my maintenance budget on the plumbing between tools instead of on the content or the site.

What I actually wanted from a CMS

Writing the requirements honestly — informed by the scar tissue — produced a very different list than the one that led me to Notion:

  • Content as typed data I own. A real schema, generating real TypeScript types, backed by a database I control. Content bugs should be compile-time bugs wherever possible.
  • The CMS inside the application, not beside it. One deploy, one repo, one set of conventions. No sync layer, because there's nothing to sync.
  • Real publishing semantics. Drafts, versions, scheduled publishing, and live preview as first-class features — guarantees enforced by the system, not conventions enforced by my discipline.
  • Server-side access control at the data layer. I wanted the option of gated content, and I wanted the gate to be structural — enforced where the data lives, impossible to bypass by hitting a different endpoint.
  • Programmatic access an AI agent can use safely. This one is new, and it reshaped the evaluation. I increasingly work with AI agents as collaborators, and I wanted a CMS where an agent could draft, revise, and manage content through a governed API with scoped permissions — not by puppeting a GUI or navigating a maze of sync jobs.

Payload matched that list almost point for point. I'd describe it as a config-as-code CMS that installs into a Next.js app, defines collections in TypeScript, generates types from the schema, stores content in Postgres, and ships versions, drafts, live preview, field-level access control, and an admin panel — all inside my own repository. I didn't take that pitch on faith. Before committing, I spiked the riskiest parts: migrating real articles with their rich text intact, and proving the access rules actually withheld a gated body from an anonymous API call. The capabilities this article praises are the ones that survived my own testing.

The architecture that replaced it

The new stack is Next.js App Router with Payload embedded, Postgres underneath (through Drizzle, a TypeScript-native ORM), and Lexical as the rich-text editor. But the stack list matters less than the seams: the specific structural decisions that make this system easier to live with than the old one. Four of them are worth stealing.

One repo layer between the CMS and the pages

No page in the site calls the CMS directly. Every route reads through a small repository module that fetches from Payload's Local API, maps documents to the exact shape the page needs, and declares its caching behavior in one place:

/** All published articles as summaries, newest first. */
export async function getAllCmsArticleSummaries(): Promise<CmsArticleSummary[]> {
  const posts = await getPublishedPosts() // cached, tagged 'posts'
  return posts.filter((p) => Boolean(p.slug)).map(toSummary)
}

This is the pattern that made the migration itself survivable: the v3 site already consumed article summaries through an interface, so the rebuild swapped the implementation behind it — Notion out, Payload in — while pages, feeds, search, and SEO routes kept working against the same shapes. The URL contract (/articles/[slug], every existing slug) survived the entire CMS replacement untouched, which is the difference between a migration and a relaunch. Years of accumulated links and search ranking will carry over because the seam exists.

If you take one structural idea from this article: put that seam in before you need it. It costs a file per collection. It pays for itself the first time anything on either side changes.

Enforce access control where the data lives

The site supports gated articles — full body for signed-in readers, teaser for everyone else. My first implementation put the gate in the application layer: a small, well-documented function that pages called before rendering a body. Clean, central, correct — and insufficient, because an app-layer gate only protects the paths that remember to call it. An independent code review of the rebuild caught two paths that didn't: the search index was flattening every article's full text into a publicly served payload, and the CMS's own auto-generated REST API would happily return a gated document's body to anyone who asked.

The durable fix wasn't more discipline. It was moving enforcement down a layer, onto the field itself:

// Posts collection — the body is invisible to anonymous reads of gated posts.
content: {
  // ...
  access: {
    read: ({ req: { user }, doc }) =>
      Boolean(user) || (doc?.access?.visibility ?? 'public') !== 'gated',
  },
}

With field-level access in place, every consumer — pages, search indexing, REST, GraphQL, future code that doesn't exist yet — inherits the constraint automatically, because the data layer never hands over what the viewer isn't entitled to see. The app-layer gate still exists for UX decisions, but it's no longer the thing security depends on. The lesson generalizes well beyond CMSes: a rule enforced at the data layer is a guarantee; the same rule enforced at the call sites is a convention. Conventions are exactly what my Notion-era "published" status was, and they fail the same way at every layer of a system.

Treat cache invalidation as a feature you build, not behavior you get

Here's the honest section. The rebuild's hardest bugs weren't in the CMS. They were in caching, and I want to document the lesson because every framework's marketing implies this part is automatic.

The site caches aggressively: statically rendered pages, a cached data layer with tagged entries, a search index built server-side. When content changes, Payload hooks fire revalidateTag and revalidatePath to purge the right entries. Getting this actually correct took three distinct, live-verified lessons:

  1. Purging data isn't purging pages. Invalidating a cache tag clears the data entry, but a statically rendered page keeps serving its prerendered shell until the path is revalidated too. Content edits looked ignored; they were rendering into pages nothing had told to re-render. Every content hook now pairs tag purges with path purges for the routes that consume it.
  2. A tag vocabulary needs a single source of truth — and a test. At one point the constants file defined one set of tag names while the data layer cached under another. Nothing failed loudly; the search index just quietly stopped updating when content changed. The fix was collapsing to one literal vocabulary and adding a unit test that pins the mapping, so the two sides can't drift apart silently again.
  3. Verify invalidation live, not in CI. Some cache behavior only exists in production infrastructure — my deployment platform's data cache, for instance, persists across deployments, which no local test will ever tell you. The only verification that counts is editing real content on a real deployment and watching the change arrive. Where live measurement showed purges weren't reliably reaching a surface, I bounded the damage with a short TTL and documented the open question rather than pretending the system was clean.

I kept a maintenance log of what was measured versus what was assumed throughout the rebuild, and that distinction — tested versus intended — turned out to be the most valuable documentation habit of the whole project. Cache invalidation didn't get easier by switching CMSes. It got ownable: the invalidation logic is now my code, in my repo, testable and debuggable, instead of an emergent property of a sync pipeline.

Make the CMS agent-operable — through the same rules humans follow

The part I'm most excited about is what the rebuild makes possible next. Payload exposes a Model Context Protocol endpoint — the emerging standard for giving AI agents structured access to tools — with API-key auth and per-collection permissions. An AI agent I work with can now find, create, and update content programmatically: draft an article as an unpublished post, attach media through a dedicated secret-gated ingestion route, update SEO metadata — all through the same access-controlled API surface, subject to the same field-level rules as any other consumer, with every write versioned and reviewable in the admin panel before anything goes live.

Compare that to the old world, where "automation" meant a swarm of jobs mutating Notion databases and hoping the sync layer agreed. The difference is governance. The old pipeline had automation with no authority model: every job was as trusted as every other, and the failure mode was silent half-states. The new system has an authority model with automation on top: scoped keys, structural access rules, human-gated publishing. My editorial standards live in version-controlled docs an agent can read; drafts land in the same review queue mine do; and publish remains a human decision by design.

In practice, a full content run now fits in one working session: an agent drafts against my style guide and creates an unpublished post, generates cover candidates into a canonical folder structure, ingests the winning image through the gated route, attaches it, and stops — because publishing is the one verb it doesn't have. Every step it takes is the same step I'd take, through the same API, under the same rules. When something goes wrong, there's one system's logs to read.

That's the piece I'd argue is genuinely new, not just better plumbing: a personal site whose content operation is safely delegable. Notion-as-CMS couldn't offer that at any price, because the trust boundary didn't exist.

An honest cost accounting

Switching wasn't free, and pretending otherwise would undercut the argument.

I gave up Notion's editor — still the best pure writing surface I've used. Payload's Lexical-based editor is good and lives inside my design system's preview, but if your content operation is mostly a person who loves writing in Notion, weigh this heavily. (Notion remains my planning surface: the editorial calendar lives there happily, because planning is what workspace tools are actually for.)

I own more system now. A database, migrations, an admin panel, editor configuration. Config-as-code means schema changes are real changes with real migrations — deliberate, versioned, and occasionally the thing your evening disappears into. The counterweight: this is visible, testable ownership replacing the invisible ownership I already had. The translation layers and half-state trackers were systems too; I just couldn't test them, type them, or fully see them.

Migration took real engineering. Moving an archive of roughly fifty articles meant a one-time script that preserved and locked every slug, converted every document from Notion's block model into Lexical's node tree, moved every cover image into the new storage layer, and deliberately landed everything as drafts — republishing was a review pass, not a bulk import. A second script audited the result: comparing node counts between source and converted documents to catch silent content loss, and backfilling metadata the transform couldn't infer. Rich text is where migrations go to die — a paragraph that drops its links, a code block that loses its language, a list that flattens — and almost none of it fails loudly. Budget for the audit pass, not just the transform, and land everything as drafts so a human eyeball is the last gate.

Where does the trade land? For me, decisively on Payload's side, but the honest framing is the general rule I'd give anyone:

A workspace tool is a fine CMS as long as your content is prose and your consumers are humans. The moment either changes — structured content, media pipelines, access control, programmatic writers — you'll start building the missing CMS yourself, one sync job at a time, in the least testable part of your stack. Better to notice early and own the real thing.

What I'd carry forward

After reading this, you should be able to evaluate your own content architecture with sharper questions. Mine, condensed:

  • Count the systems that must agree for you to publish. Each one is a failure point and a debugging surface. My old answer was five; my new answer is one.
  • Ask where your rules are enforced. Anything that matters — published status, access control, content shape — should be a guarantee in the data layer, not a convention in the app layer. Conventions are what code reviews find leaking.
  • Put a repository seam between content and pages on day one. It's the cheapest insurance in web architecture, and it's the reason my URLs survived a full CMS transplant.
  • Trust nothing about caching until you've watched it work on real infrastructure. Keep a written record of what you measured versus what you assumed.
  • Design for programmatic collaborators, even if you don't have them yet. An API-first, access-controlled CMS makes AI-assisted content operations a governed capability instead of a fragile hack.

The next time you catch yourself building a job queue to track which half of your content pipeline succeeded, stop and run the audit above. That queue is your architecture telling you it wants to be replaced. Mine was right.