Build OceanJiang.com portfolio

In Progress
  • Technology
  • Education
  • Creative

You're reading this on a site that gets its content from Notion. This post lives in the Notion database the site reads from. The site had to exist before this post could, but this post describes how the site was built. I leaned into the loop rather than paper over it. Since the first version of this post, the loop got tighter: you're now reading it on a real custom domain, oceanjiang.com, and the whole thing is open source at G-OceanJiang/oceanjiang-com.

The Setup: Notion as a Free CMS

Notion as a headless CMS, Astro doing the rendering, Cloudflare Pages handling delivery. Total hosting cost: $0/month.

The data model is a Notion database where each row is a project. Each row has structured properties (title, description, tags, status, URL) and links to a full Notion page for the long-form write-up — like this one. At build time, Astro queries the Notion API, pulls the structured data and the page blocks, and renders everything as a fully static site. No server, no runtime API calls, no database to maintain.

The $0 is a side effect. Notion is already where I take notes and draft things. Using it as the CMS means zero context-switching — I write here, push a rebuild, and it's live.

How the Data Flows

The pipeline has two stages.

Stage 1 — Index page. A single getPortfolioProjects() call fetches all published rows and renders the project grid.

Stage 2 — Detail pages. Astro's getStaticPaths() runs at build time, calls getPortfolioProjects() again, and returns one path per project. Each path carries the project metadata as props. The detail page then calls fetchPageBlocks(pageId) to fetch the content and renders it block by block.

javascript
export async function getStaticPaths() {
  const projects = await getPortfolioProjects();
  return projects.map((project) => ({
    params: { id: project.id },
    props: { project },
  }));
}

Everything happens at build time. Cloudflare gets a folder of HTML files. Fast, cheap, no cold starts.

The Gotcha

Notion has two integration types: OAuth integrations (for apps that need user authorization flows) and internal integrations (for your own workspace, token-based). I needed the latter. Create it at notion.so/my-integrations, drop the secret token into .env as NOTION_TOKEN, done.

The part that bit me: connecting the integration to the database is not enough. You have to explicitly share each page with the integration too — via the "Connect to" option in the page's top-right menu. Share the database but not the child pages, and the API returns the project rows but gives you a 404 on the page body. Spend five minutes on this, not two hours.

Rendering Notion Blocks

Notion's API returns page content as an array of block objects — paragraphs, headings, code blocks, callouts, lists. No built-in markdown export. You write the renderer yourself.

I built a complete block renderer rather than a minimal one. The minimal path handles only the blocks you use today and throws a fallback for everything else — which means every new block type in a future post is a code change and a redeploy.

The trickiest part isn't the block types, it's inline annotations. A single paragraph can contain multiple runs, each with its own combination of bold, italic, code, and strikethrough. richTextToHtml() walks each annotation object and wraps the text in the right tags.

javascript
function richTextToHtml(richText) {
  return richText.map((t) => {
    let html = escapeHtml(t.plain_text);
    const a = t.annotations;
    if (a.code) html = `<code>${html}</code>`;
    if (a.bold) html = `<strong>${html}</strong>`;
    if (a.italic) html = `<em>${html}</em>`;
    if (a.strikethrough) html = `<s>${html}</s>`;
    if (t.href) html = `<a href="${t.href}">${html}</a>`;
    return html;
  }).join("");
}

Not glamorous. Does the job.

The Reading Progress Bar

The thin bar at the top of each post that fills as you scroll is a CSS scroll-driven animation — one of the nicer recent platform additions. It scales a single element horizontally as the page scrolls, which is cheaper than animating width.

css
@keyframes grow-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
.progress-bar {
  transform-origin: left;
  animation: grow-progress auto linear;
  animation-timeline: scroll(root block);
}

No JavaScript, no scroll listeners, no requestAnimationFrame. Support is solid in Chrome and Safari; Firefox is close. For the gap, a small JS fallback sets a CSS custom property on scroll — same result, slightly more code.

Deployment, and then Going Live

The basics: connect the GitHub repo, set the build command (npm run build), set the output directory (dist), add the environment variables. Cloudflare picks those up at build time.

That's the happy-path description. Going live was less tidy. I created the repo (G-OceanJiang/oceanjiang-com) and pushed it with the gh CLI, created the Pages project through the Cloudflare API, wired the GitHub integration and env vars, and set the GitHub Actions secrets. Then the domain saga began. Three stumbles worth recording:

  1. Nameservers. Switched Namecheap from "Custom DNS" to Cloudflare's nameservers (brenda/matteo.ns.cloudflare.com). Then I briefly reverted to Namecheap BasicDNS, which broke delegation entirely — a 522 — until I put the Cloudflare nameservers back. Lesson: once Cloudflare runs DNS, leave the nameservers alone.
  2. Stuck "pending" verification. Cloudflare Pages refused to verify the custom domain, sitting on "pending" indefinitely. Fix: delete the domain from the Pages project and re-add it. It verified immediately the second time.
  3. DNS cleanup. The www CNAME pointed at a Namecheap parking page; I pointed it at pages.dev. Removed stale email-forwarding MX records while I was in there.

The Env-Var Double-Feature

This one was two bugs wearing a trench coat.

Bug 1 — import.meta.env vs process.env. NOTION_TOKEN read fine locally from .env, but on Cloudflare every Notion call failed with "API token is invalid" — the token was undefined. Cause: Vite only inlines vars from .env files and VITE_-prefixed OS vars at bundle time. Cloudflare injects dashboard vars into the OS environment, but Vite doesn't surface those as import.meta.env during prerender. Fix — read Node's environment first:

javascript
const token = process.env.NOTION_TOKEN ?? import.meta.env.NOTION_TOKEN;

Bug 2 — wrangler.toml. Even with that fix, the dashboard build vars still didn't reach the build. Cause: when Cloudflare Pages detects a wrangler.toml, it switches into a beta wrangler-config mode that treats dashboard vars as runtime-only (for Workers). Build-time vars then have to live in [vars] in the toml — which can't hold secrets. Fix: delete wrangler.toml. A static Pages site doesn't need one; the build command and output dir live in the dashboard.

If a build var is undefined on Cloudflare but fine locally, suspect these two before anything else: read process.env first, and make sure there's no wrangler.toml quietly hijacking your build config.

Locking It Down

A quick security pass. Cloudflare Pages reads a public/_headers file — plain text, no build step — so I added one setting HSTS, X-Frame-Options: DENY, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy. The core directives:

javascript
/*
  Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff

Confirmed no secrets in git history, .env gitignored, SSL valid (Google Trust Services via Cloudflare).

Bone & Oxblood: A Redesign

The first version looked like a default template, and it knew it. I picked an editorial, literary direction from four palette options and called it Bone & Oxblood.

Dark by default: a warm near-black background (#14110f), bone-white inset panels (#f4efe6), and an oxblood accent — #b23a36 lifted for dark sections, #7c2128 true on bone. The type stack is Fraunces for display headings (with optical sizing), Newsreader for body serif, and JetBrains Mono for meta — dates, tags, nav, eyebrows. Sections are full-bleed and alternate dark ↔ bone, with an inner .wrap column (max-width 940px, centered) holding the content.

The payoff is the theming. The sections alternate, but I didn't want every component to know which background it sits on. So .section--bone just locally redefines the CSS custom properties:

css
.section--bone {
  --text: #1a1714;
  --muted: #6b5d4f;
  --border: #d8cdba;
  --accent: #7c2128;
}

Any token-based child — a ProjectCard, say — re-themes purely by location, with zero per-component code, as long as it references var(--text) and never hardcodes a color. One gotcha: a scoped section padding shorthand clobbered .wrap's horizontal gutter. Use padding-top/padding-bottom longhand and let .wrap own the sides.

This also closes a loop. The old version of this post listed "dark mode" as future work. It isn't anymore — the site is dark by default, with bone sections as the deliberate light counterpoint. Different problem, solved sideways.


What's Next

The source is public at G-OceanJiang/oceanjiang-com — read it, or lift the token-swap pattern for your own site. And you're reading this on the very site, and now the very domain, it describes. The loop holds.