YeagerZhao
HomeBlogProductJourneyAbout
Back

How I Built YeagerZhao Blog: A Complete Architecture with Next.js 15, Notion, and Aliyun OSS

2026 · 06 · 23
Vibe Coding个人网站
ContentsHow I Built YeagerZhao Blog: A Complete Architecture with Next.js 15, Notion, and Aliyun OSS1. What this project is2. Architecture overview3. Tech stack4. Directory structure5. Routing6. Content modeling: four Notion databases7. The server-side data layer8. The publish pipeline: from local Markdown to Notion9. Image pipeline: sharp + watermark + OSS three-tier realtime derivation10. NotionBlocks: my own Notion → React renderer11. Visual system: Tailwind v4 + CSS variables12. UI layering: views + components + effects13. i18n and zh/en routing14. Caching strategy15. API routes16. SEO and site indexing17. Docker + Nginx + Aliyun ECS deployment18. Day-to-day workflow19. Key trade-offs20. Limitations and next steps21. Wrap-up

How I Built YeagerZhao Blog: A Complete Architecture with Next.js 15, Notion, and Aliyun OSS

A write-up for my future self, and for anyone building a personal site who wants to copy the recipe. It walks through the whole stack — directory tree, routes, data layer, content publish pipeline, image processing, caching, i18n, visual system, and finally Docker + Nginx deployment. The goal is to explain YeagerZhao_Blog clearly enough that someone else could read it, and that I myself can maintain it later.

Home page
Home page

1. What this project is

YeagerZhao_Blog is my personal website, live at https://yeagerzhao.com. It uses Notion as a headless CMS, Next.js 15 for page rendering, SEO, API gateway and caching, Aliyun OSS for photo storage, and Docker + Nginx on Aliyun ECS for hosting.

The four problems it sets out to solve:

  • Writing should be light. Content lives in Notion — no PR or CI step every time I tweak a paragraph.
  • Reading should be fast. Visitors see a custom site (with my own domain and visual identity), not the grey-white Notion default template.
  • Design should be free. Fonts, colors, motion, article typography — all controlled in code.
  • Photos should be protected. Every album photo is locally compressed to a ~8MP master, embedded with a DWT-DCT-SVD frequency-domain invisible watermark, then uploaded to OSS. The browser sees on-the-fly compressed derivatives; the master is only served when you click to enlarge.

In one line: Notion produces content, Next.js publishes content, OSS distributes images, Nginx + Docker keep it running.

2. Architecture overview

The system as one picture:

Architecture overview
Architecture overview
User browser
    |
    | HTTPS
    v
Nginx on Aliyun ECS
    |
    | /_next/static gets long cache; everything else proxies to 127.0.0.1:3000
    v
Docker container: yeagerzhao-blog
    |
    | Next.js 15 standalone server
    | ├─ RSC rendering (home / blog / product / journey / about)
    | ├─ unstable_cache + in-process memory layer (1h TTL)
    | ├─ API routes: /api/revalidate, /api/notion-image
    | └─ generateStaticParams pre-renders all slugs
    v
        ┌─────────────────────────┬──────────────────────────┐
        v                         v                          v
   Notion API                  OSS (image host)        Local SKILLS scripts
   ├─ content DB              ├─ album masters         ├─ publish-content.mjs
   │  (Blog + Product)        │  (8MP + watermark)    ├─ publish-journey.mjs
   ├─ journey DB              └─ ?x-oss-process=...    ├─ rewatermark-album.mjs
   ├─ journey_photo DB           thumb/preview/full    └─ publish-about.mjs
   └─ about DB                   derived at the edge

There's no traditional "separate backend." Next.js plays four roles at once:

  1. Page server: home, blog list/detail, product list/detail, journey list/album detail, about — all RSC.
  2. Data gateway: /api/notion-image proxies Notion's temporary S3 links; /api/revalidate gives scripts a push endpoint.
  3. Content adapter: turns the four Notion databases into the TypeScript types the site uses.
  4. Static asset server: image CDN duties are taken over by OSS; Next.js only serves its own _next/static and public/.

This "content/asset separation" works really well for a personal site: articles and photos update at different cadences and need different cache strategies — no point cramming them into the same Node process.

3. Tech stack

LayerTechVersionRole
App frameworkNext.js15.5App Router, RSC, ISR, API Routes, standalone output
UI runtimeReact19.1RSC + client hydration
Type systemTypeScript5Core types: BlogPost / Product / JourneyItem / AboutSection etc.
StylingTailwind CSSv4CSS-first: @theme inline + CSS variables
Content sourceNotion—4 databases: content / journey / journey_photo / about
Notion SDK@notionhq/client5.22v5 dataSources API + custom compatibility shim
i18nnext-intl4.9/zh, /en routing + UI translations
Image uploadali-oss6.23Local scripts write to the OSS bucket
Image compressionsharpdevmozjpeg q=88, 4:2:0 chroma subsampling, MP-budget scaling
Invisible watermarkinvisible-watermark (Python)conda envDWT-DCT-SVD frequency-domain watermark
Fonts@fontsource-variable/*5.2Noto Sans/Serif SC + Newsreader + Spline Mono
DeploymentDocker—Multi-stage standalone image
EdgeNginx—HTTPS, reverse proxy, long-cache for _next/static

A few notable absences — dependencies I tried and dropped: I used react-notion-x for content rendering early on, but it bundled too much, dragged in its own CSS, and forced everything onto the client. I replaced it with a server-side Notion-blocks renderer I wrote myself; the bundle dropped a lot — there's a dedicated section on this later. I also dropped my own use-cached-fetch.ts three-layer cache: Next.js 15's unstable_cache + tags already does what I want, no need for an extra client-side state machine. And I removed next-themes (used for dark/light mode switching) — a single color palette is enough to carry the whole visual system, so the dependency went too.

4. Directory structure

Organized by Next.js App Router conventions, but with an explicit split between "server-side data layer" (server/) and "presentation layer" (ui/):

YeagerZhao_Blog/
├── messages/                       # next-intl translations
│   ├── zh.json
│   └── en.json
├── public/                         # Static assets (favicon, images)
├── src/
│   ├── app/                        # Next.js App Router
│   │   ├── [locale]/
│   │   │   ├── layout.tsx          # locale-scoped layout (fonts, Header/Footer)
│   │   │   ├── page.tsx            # home
│   │   │   ├── blog/page.tsx       # blog list
│   │   │   ├── blog/[slug]/page.tsx
│   │   │   ├── product/page.tsx    # product list
│   │   │   ├── product/[slug]/page.tsx
│   │   │   ├── journey/page.tsx    # journey/album list
│   │   │   ├── journey/[slug]/page.tsx
│   │   │   ├── about/page.tsx      # about page
│   │   │   ├── loading.tsx
│   │   │   └── not-found.tsx
│   │   ├── api/
│   │   │   ├── notion-image/route.ts
│   │   │   ├── posts/route.ts
│   │   │   ├── products/route.ts
│   │   │   └── revalidate/route.ts # on-demand regeneration (scripts/Notion push)
│   │   ├── layout.tsx              # top-level root layout
│   │   ├── page.tsx                # root / redirects to /zh
│   │   ├── robots.ts
│   │   └── sitemap.ts
│   ├── server/                     # server-only logic (never bundled to client)
│   │   ├── cache/
│   │   ├── images/                 # Notion image proxy handler
│   │   └── notion/
│   │       ├── client.ts           # singleton Notion client
│   │       ├── content.queries.ts  # unified Blog + Product queries
│   │       ├── blog.queries.ts     # thin wrapper
│   │       ├── product.queries.ts  # thin wrapper
│   │       ├── journey.queries.ts  # album queries
│   │       ├── about.queries.ts
│   │       ├── page-content.ts     # recursively pulls Notion blocks (depth 4)
│   │       └── mappers.ts          # property → TypeScript type
│   ├── ui/                         # presentation layer (client / server-mixed)
│   │   ├── components/             # primitives (Button, Card, Tag, Badge, Panel)
│   │   ├── effects/                # decorative effects like SilkHero
│   │   ├── styles/                 # global CSS + Tailwind v4 variables
│   │   ├── tokens/                 # CSS variable definitions
│   │   └── views/                  # page-level view components
│   │       ├── home/HomeView.tsx
│   │       ├── blog/BlogIndexView.tsx
│   │       ├── product/ProductIndexView.tsx
│   │       ├── journey/JourneyView.tsx
│   │       ├── journey/JourneyAlbumView.tsx
│   │       ├── about/AboutView.tsx
│   │       └── article/
│   │           ├── ArticleDetailView.tsx
│   │           ├── ArticleToc.tsx
│   │           └── NotionBlocks.tsx   # my own Notion renderer
│   ├── i18n/
│   ├── lib/
│   │   ├── oss-image.ts            # ossThumb(url, {preset}) builds the three-tier URL
│   │   ├── embed-video.ts          # Bilibili / YouTube embed URL parser
│   │   └── constants.ts
│   └── types/
├── SKILLS/                         # content publishing toolchain
│   ├── publish-blog-product/
│   ├── publish-journey-album/
│   ├── publish-journey-video/
│   ├── publish-about/
│   ├── operations/                 # revalidate-site / sync-notion / setup-schema
│   └── _shared/content-tools.mjs   # Notion client + SDK v5 compatibility shim
├── Dockerfile
├── deploy.sh
├── middleware.ts
└── next.config.ts

The load-bearing files:

FileJob
src/server/notion/content.queries.tsUnified Blog + Product queries (filtered by ContentType field)
src/server/notion/journey.queries.tsAlbum + photo queries + relation
src/server/notion/page-content.tsRecursively pulls a Notion page's block tree (depth 4)
src/ui/views/article/NotionBlocks.tsxMy own Notion blocks → React renderer (replaces react-notion-x)
src/ui/views/article/ArticleToc.tsxClient-side TOC with scroll-spy + smooth scroll
src/ui/views/journey/JourneyAlbumView.tsxAlbum detail: carousel, keyboard navigation, lightbox
src/lib/oss-image.tsURL builder for OSS realtime image processing
SKILLS/publish-journey-album/publish-journey.mjsFirst-time album publish: sharp + watermark + OSS PUT + Notion
SKILLS/publish-journey-album/rewatermark-album.mjsRe-watermark already-published albums after pipeline changes (no Notion writes)
SKILLS/publish-journey-album/watermark.pyPython invisible-watermark encode/decode CLI
DockerfileMulti-stage standalone build
middleware.tsnext-intl language routing

5. Routing

Eight page routes under src/app/[locale]/:

/zh                              home
/en
/zh/blog        /zh/blog/[slug]   blog list + detail
/en/blog        /en/blog/[slug]
/zh/product     /zh/product/[slug] product list + detail
/en/product     /en/product/[slug]
/zh/journey     /zh/journey/[slug] journey list + detail
/en/journey     /en/journey/[slug]
/zh/about                          about
/en/about

src/i18n/routing.ts:

export const routing = defineRouting({
  locales: ["zh", "en"],
  defaultLocale: "zh",
});

middleware.ts plugs next-intl into the request pipeline:

import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";

export default createMiddleware(routing);

export const config = {
  matcher: ["/(zh|en)/:path*", "/((?!_next|_vercel|api|.*\\..*).*)"],
};

All detail routes use generateStaticParams() to pre-render — each slug outputs both zh and en static paths. Visitors hit ISR-cached HTML when fresh; once expired, Next.js regenerates in the background.

6. Content modeling: four Notion databases

I use exactly four Notion databases — not more. The trade-off: highly homogeneous content shares a single table (with a ContentType field as discriminator), while highly heterogeneous content (like photos) gets its own.

Databaseenv variableRole
contentNOTION_CONTENT_DATABASE_IDBlog + Product share this (separated by ContentType field)
journeyNOTION_JOURNEY_DATABASE_IDMetadata for albums / vlogs / edits / travel notes
journey_photoNOTION_JOURNEY_PHOTO_DATABASE_IDPhotos table, linked back to journey via a Journey relation
aboutNOTION_ABOUT_DATABASE_IDAbout page sections: profile / timeline / highlight (separated by SectionType)

The content database

Fields (selected):

FieldTypeNote
TitletitleArticle/project title
ContentTypeselect (blog / product)Distinguishes two kinds of content in one table
Slugrich_textURL slug, shared between zh/en
Languageselect (zh / en)Language variant
Summaryrich_textExcerpt
DatedatePublish date
Tagsmulti_selectTags
PublishedcheckboxLive or not
FeaturedcheckboxPinned on home
CoverImagefilesCover (uploaded via Notion file_upload by the script)
ProductUrlurlProduct-only

The primary-key concept is the triple ContentType + Slug + Language — a single article's zh and en variants are two rows sharing the same slug; the same slug can also coexist as blog and product ContentTypes (this very article is published as both — see below).

Notion content database
Notion content database

Why journey is split from journey_photo

Albums and photos are two tables connected by a relation. Reasoning:

  • An album (journey) needs title, place, date, mood, cover — the "list card" fields
  • An album has N photos (journey_photo), each only needing ImageUrl + Order + back-reference
  • With a relation, the list page only queries the journey table; only the detail page pulls photos

journey table (excerpt): Title / Slug / Language / Type (album/vlog/anime-edit/travel-note) / Date / Place / Summary / Feeling / CoverImageUrl / VideoUrl / Published.

journey_photo table: Name / Journey (relation) / ImageUrl / Order / Published.

The about table's single-table-multi-section design

The about page is heterogeneous: profile (portrait + tagline), timeline (school/work history), highlights (notable projects/works). Three separate tables would be over-fragmented, so it's one table with a SectionType field:

SectionType: select (profile / timeline / highlight)

On the code side, getAboutProfile() / getAboutTimeline() / getAboutHighlights() each pass a different filter.

7. The server-side data layer

The data layer lives entirely under src/server/notion/ and runs only on the server (never bundled to the client). It has three real jobs:

7.1 A compatibility shim for the Notion SDK v5 dataSources API

@notionhq/client v5 renamed database queries from notion.databases.query() to notion.dataSources.query() (and the parameter from database_id to data_source_id). To keep the code compatible with v4 / v5, SKILLS/_shared/content-tools.mjs ships a small shim:

export async function queryNotionDataSource(notion, args) {
  if (notion.databases?.query) return notion.databases.query(args);          // v4 stable
  if (notion.dataSources?.query) {                                            // v5 beta
    const { database_id, ...rest } = args;
    return notion.dataSources.query({ data_source_id: database_id, ...rest });
  }
  throw new Error("Installed Notion SDK does not support database/data source queries");
}

The same shim exists on the site side under src/server/notion/, and every query goes through it. When v6 changes things again, only this function changes.

7.2 Translating Notion properties into TypeScript types

src/server/notion/mappers.ts defines a set of mappers like extractBlogPost(page) and extractJourneyItem(page). They do three things:

  • Flatten Notion's rich_text arrays into strings (concatenating plain_text)
  • Extract name from select / multi_select
  • Apply reasonable defaults (missing field → empty string / empty array, never throws)

Output: strongly typed BlogPost, Product, JourneyItem, JourneyPhoto, AboutProfile, etc., defined in src/types/.

7.3 Wrapping queries with unstable_cache

Each query function is wrapped in Next.js's unstable_cache, with cache tags:

import { unstable_cache } from "next/cache";

export const getBlogPosts = (locale: Locale) =>
  unstable_cache(
    async () => fetchFromNotion(...),
    ["blog-posts", locale],
    { revalidate: 3600, tags: ["notion", "content", "blog-posts"] }
  )();

ISR uses the same tag system:

  • /api/revalidate receives a push → revalidateTag("blog-posts") → next request re-fetches from Notion
  • I can also nuke everything at once with revalidateTag("notion") (after a deploy I take this blunt route)

8. The publish pipeline: from local Markdown to Notion

I deliberately don't let the "writing" step depend on Notion's native editor — Notion's Markdown editing on the web is cramped, and it's hard to do local preview and git versioning. So the publish pipeline goes:

Local D:\...\YeagerZhao\content\blog\<slug>\
├── manifest.json        # metadata
├── draft.zh.md          # Chinese body
├── draft.en.md          # English body
└── images/              # figures
    └── 01-xxx.png       # referenced by ![](images/01-xxx.png) in the markdown

    ↓ npm run publish:content -- --dir "..."

SKILLS/publish-blog-product/publish-content.mjs
├── parse markdown → Notion blocks (custom parser)
├── local images → uploadNotionFile() → file_upload API
├── write two rows into the Notion content DB: zh + en (Published=false)
└── deduplicate via ContentType + Slug + Language triple

    ↓ I review in Notion, flip Published to true

    ↓ npm run revalidate:site

    ↓ Next.js revalidateTag("notion") → ISR re-fetches

Site /zh/blog/<slug> goes live

The manifest.json schema (the script validates):

{
  "type": "blog",
  "slug": "yeagerzhao-blog-architecture",
  "date": "2026-06-22",
  "tags": ["Vibe Coding", "个人网站"],
  "featured": true,
  "cover": "images/00-cover.png",
  "zh": { "title": "...", "summary": "..." },
  "en": { "title": "...", "summary": "..." }
}

What this SKILL setup buys me:

  • Writing stays local: I write Markdown in VS Code, commit to git; the publish script is just "upload to Notion + trigger revalidate"
  • Images stay local too: ![](images/xxx.png) references local files, the script uploads them to Notion's file_upload API and writes the URL into the block
  • Bilingual parallelism: One publish writes both zh + en rows, sharing the slug
  • Idempotent: Re-running the same slug throws; to update properties use --overwrite, to replace the body, archive the old Notion row and re-run

9. Image pipeline: sharp + watermark + OSS three-tier realtime derivation

This is the part of the project I'm most proud of — both the most carefully built and the rarest among personal blogs.

9.1 Four things to solve

GoalHow
① Privacy (strip EXIF GPS / camera info)sharp .rotate() bakes orientation into pixels, then defaults to dropping all metadata
② Bound master size (target ≤ 1.5MB)sharp scales to ~8 megapixels + JPEG q=88 mozjpeg 4:2:0
③ Anti-theft (be able to prove ownership)Python invisible-watermark embeds "YeagerZhao" into the DWT-DCT-SVD frequency domain
④ Browser fetches only the size it needsOSS stores one master per photo; thumb/preview/full are derived live via ?x-oss-process=...

9.2 Local: MP-budget scaling

I started with "long-edge cap at 3500px" — then ran into a 13223×2360 panorama. Long-edge capping reduced it to 3500×625 — short edge only 625px, visibly soft. Switching to area budget solved it cleanly:

// SKILLS/publish-journey-album/publish-journey.mjs
const TARGET_MASTER_MP = 8_000_000;

async function processPhoto(srcPath) {
  const meta = await sharp(srcPath).rotate().metadata();
  const sourceMP = meta.width * meta.height;
  const scale = Math.min(1, Math.sqrt(TARGET_MASTER_MP / sourceMP));
  const newW = Math.round(meta.width * scale);
  const newH = Math.round(meta.height * scale);

  return sharp(srcPath)
    .rotate()                                  // honor EXIF orientation
    .resize(newW, newH)                        // 8MP budget
    .jpeg({ quality: 88, mozjpeg: true, chromaSubsampling: "4:2:0" })
    .toBuffer();                               // metadata dropped by default
}

Why 8MP: 4K screens are 8.3MP — that's exactly enough; anything larger will be downscaled by the browser anyway, which is wasted bandwidth.

Real numbers:

AlbumSourceOld (3500 long edge)New (8MP)
Standard 3:2 landscape (24MP)11MB3500×2334 (8.2MP) 800KB3464×2310 (8.0MP) 798KB
Panorama 5.6:1 (31MP)19MB3500×625 (2.2MP) 381KB6695×1195 (8.0MP) 1294KB

Panorama short edge went from 625 to 1195, nearly doubled; master size only grew by ~900KB — a great trade.

9.3 Local: invisible watermark embedding

After sharp writes its output to a temp JPEG, watermark.py is invoked:

execFileSync(PYTHON_EXE, [
  WATERMARK_PY, "encode",
  "--input", tmpIn, "--output", tmpOut,
  "--text", "YeagerZhao",
  "--method", "dwtDctSvd",            // SVD variant survives JPEG re-encoding better than plain dwtDct
  "--quality", "80",                  // output JPEG q=80 → final master
]);

Why dwtDctSvd: in testing, dwtDct + q=85 already lost bytes (decoded 0/10), while dwtDctSvd at q=80 still recovered 10/10 bytes — meaning even if someone runs another JPEG round-trip on the image, the string "YeagerZhao" can still be fully recovered with frequency-domain decoding tools.

The Python environment is isolated via conda, only two dependencies:

conda create -n yeagerzhao python=3.11 -y
conda activate yeagerzhao
pip install invisible-watermark opencv-python

Node points at conda's python.exe via a PYTHON_EXE env variable, so there's no reliance on system PATH.

9.4 OSS: one master, three live-derived tiers

In the OSS bucket, every image is stored only as a master, under the key yeagerzhao/journey/<slug>/photos/NN-<original>.jpg. To get different sizes, the frontend appends ?x-oss-process=image/resize,p_X/format,webp/quality,q_Y, and OSS edge nodes resize, transcode to webp, and adjust quality on the fly.

Presets in src/lib/oss-image.ts:

const PRESETS = {
  // List / grid covers — 25% of source, q=70 → ~50KB
  thumb:   { percent: 25, quality: 70 },
  // In-album single photo (carousel) — 40% of source, q=78 → ~200KB
  preview: { percent: 40, quality: 78 },
  // lightbox: returns the master URL untouched
};

export function ossThumb(url, { preset }) {
  if (preset === "full") return url;               // master, byte-for-byte
  if (!isOssUrl(url)) return url;                  // non-OSS (Bilibili / YouTube thumbnails) passes through
  const { percent, quality } = PRESETS[preset];
  const sep = url.includes("?") ? "&" : "?";
  return `${url}${sep}x-oss-process=image/resize,p_${percent}/format,webp/quality,q_${quality}`;
}

p_N is percentage of the source's own dimensions, not a fixed width — which means panoramas, portraits and squares all scale proportionally without distortion. format,webp makes OSS re-encode to webp, saving another ~30% over JPEG.

Image pipeline
Image pipeline

9.5 Re-watermarking already-published albums

My image pipeline has been iterated — from no compression, to long-edge cap, to MP budget, from dwtDct to dwtDctSvd — and every adjustment requires a batch pass over already-published albums. So I wrote a separate rewatermark-album.mjs:

# single album
node SKILLS/publish-journey-album/rewatermark-album.mjs --dir "<original source folder>"

# batch
for d in "E:/Media/Photo/24Select/"*/ ; do
  node SKILLS/publish-journey-album/rewatermark-album.mjs --dir "$d"
done

Key design: OSS keys are reconstructed by the same <slug>/photos/NN-<original>.jpg rule and PUT overwrites the existing object — so Notion's ImageUrl / CoverImageUrl never change, no Notion writes, no Notion cache invalidation needed. Just npm run revalidate:site to make the CDN pull the new version.

The last time I ran this, it re-watermarked 7 albums (87 photos total) in about 5 minutes.

9.6 Verifying the watermark survived

curl -s -o /tmp/test.jpg "<master URL>"
python SKILLS/publish-journey-album/watermark.py decode \
  --input /tmp/test.jpg --method dwtDctSvd --length 80 \
  --expect YeagerZhao
# Expect last line: match: 10/10 bytes vs expected 'YeagerZhao'

--length 80 is because during encode I repeat "YeagerZhao" 8 times (80 bytes) for redundancy — as long as the first 10 bytes decode cleanly it's OK; the remaining 70 give a majority-vote safety net. After every publish I sample a few photos and run this.

10. NotionBlocks: my own Notion → React renderer

I started with react-notion-x. Two problems eventually pushed me to write my own:

  1. Bundle size: it ships the full Notion react renderer to the client, plus prismjs, katex, mermaid — a single blog detail page's client bundle ballooned past 200KB
  2. Forced client component: <NotionRenderer> must run on the client, which dragged the whole article off RSC and back to client rendering — meaning the first-paint HTML had no body content, bad for SEO and for first-meaningful-paint

The replacement is NotionBlocks.tsx:

// Server component — renders Notion blocks straight to HTML
export function NotionBlocks({ blocks }: { blocks: NotionBlock[] }) {
  return <>{blocks.map((b) => renderBlock(b))}</>;
}

function renderBlock(block: NotionBlock) {
  switch (block.type) {
    case "paragraph":  return <p>{renderRichText(block.paragraph.rich_text)}</p>;
    case "heading_1":  return <h1>{renderRichText(...)}</h1>;
    case "heading_2":  return <h2>{renderRichText(...)}</h2>;
    case "heading_3":  return <h3>{renderRichText(...)}</h3>;
    case "code":       return <CodeBlock language={block.code.language}>{...}</CodeBlock>;
    case "image":      return <Image src={proxyUrl(block.image)} alt="..." />;
    case "quote":      return <blockquote>{renderRichText(...)}</blockquote>;
    case "bulleted_list_item": /* collapse adjacent items into a <ul> */
    case "callout":    return <Callout icon={...}>{...}</Callout>;
    // ...
  }
}

renderRichText handles Notion annotations (bold/italic/code/strikethrough/underline/color + link). Code blocks get hljs-based syntax highlighting (CSS-only, no runtime JS pulled in).

Results:

  • Blog-detail client bundle shrank by ~180KB
  • No more overriding .notion-* classes — body styles inherit the site-wide font/spacing/link treatments directly
  • The whole article is RSC, first-paint HTML carries the body (SEO-friendly)

Article sidebar TOC (the one client component left)

ArticleToc.tsx is the only remaining client component in the detail page — it needs scroll-spy and smooth-scroll on click:

"use client";
function ArticleToc({ headings }) {
  const [active, setActive] = useState(headings[0]?.id);

  useEffect(() => {
    const io = new IntersectionObserver((entries) => {
      const visible = entries.find((e) => e.isIntersecting);
      if (visible) setActive(visible.target.id);
    }, { rootMargin: "-50% 0px -50% 0px" });
    headings.forEach((h) => {
      const el = document.getElementById(h.id);
      if (el) io.observe(el);
    });
    return () => io.disconnect();
  }, [headings]);
  // render ...
}

headings is extracted on the server during NotionBlocks parsing — the client doesn't re-parse the body.

11. Visual system: Tailwind v4 + CSS variables

The site has one color palette only — no light/dark switch. The reason is plain: a single palette (deep-night blue + moonlight text + gold accents) already carries the whole visual system, and adding a toggle would mean tuning contrast / link colors / code block backgrounds for two color decks — twice the work, marginal reader benefit. So the early next-themes dependency was dropped too (see section 3 on "dependencies that left").

Tailwind v4's CSS-first approach lets "theme variables" and "utility classes" share one mechanism:

/* src/ui/styles/globals.css */
@import "tailwindcss";

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-accent: var(--c-gold);
  --font-sans: var(--font-noto-sans-sc), "Microsoft YaHei", system-ui, sans-serif;
  --font-serif: var(--font-newsreader), serif;
  --font-mono: var(--font-spline-sans-mono), monospace;
}

:root {
  /* a palette of deep-night blue + moonlight + gold accents */
  --background: #0a0f1a;
  --foreground: #ecede4;
  --c-navy: #0e2740;
  --c-steel: #3e6b89;
  --c-olive: #7a8b6a;
  --c-gold: #c8a85b;
  --text-strong: #f5f1de;
  --text-muted: #8d96a8;
}

Font strategy:

  • English headings: Newsreader (serif, bookish)
  • Chinese: Noto Sans/Serif SC (loaded via fontsource-variable, split by weight)
  • Code: Spline Sans Mono
  • Chinese system fallback: Microsoft YaHei / PingFang

The background is not an image, but two CSS-generated layers:

  • body::before: SVG turbulence noise — paper grain
  • body::after: multiple radial-gradients — watercolor blocks

This gives the page texture without loading any background image.

One small detail: text selection and long-press download are disabled site-wide by default; only article bodies and product detail pages re-enable selection. Lists and navigation feel more "app-like," and reading bodies still lets you copy quotations. For photos in albums, I take it further — onContextMenu and onDragStart both preventDefault, so right-click "Save image as" doesn't go anywhere.

12. UI layering: views + components + effects

src/ui/
├── views/         # page-level views (each page.tsx imports one View)
├── components/    # primitives + compositions (Card / Button / Tag / Badge / Panel / Reveal / Timeline)
├── effects/       # decorative visual effects (SilkHero and friends)
├── styles/        # global CSS
└── tokens/        # CSS variable definitions

Separating views/ from components/ keeps page.tsx thin — it does just two things: "fetch data + hand it to a View," e.g.:

// src/app/[locale]/blog/page.tsx
export default async function Page({ params }) {
  const { locale } = await params;
  const posts = await getBlogPosts(locale);
  return <BlogIndexView lang={locale} posts={posts} />;
}

Inside each View, smaller building blocks like <Panel>, <Card>, <Tag>, <SectionBlock> get composed. The Server / Client boundary:

TypeRepresentative componentsTrait
ServerAll page.tsx, HomeView, BlogIndexView, NotionBlocksFetches data on the server, emits HTML
ClientSiteNav, LanguageSwitcher, ArticleToc, JourneyAlbumView, RevealNeeds hooks, events, scroll listeners, or browser state

A simple rule: anything that can be done on the server is done on the server; only interaction, scroll, and browser state cross into the client.

13. i18n and zh/en routing

Two layers of i18n:

Layer 1: UI translations — messages/zh.json and messages/en.json hold every nav label, button, page title, and empty-state string. In components:

const t = useTranslations("common");
t("back");  // → "返回" / "Back"

Layer 2: routing — /zh and /en are two language entry points; the Link from src/i18n/navigation.ts handles locale-aware navigation:

import { Link } from "@/i18n/navigation";

<Link href="/blog/my-post">Read</Link>
// renders as /zh/blog/my-post under /zh, as /en/blog/my-post under /en

Language strategy for content: every article lives in Notion as two rows — zh + en — and getBlogPosts(locale) filters by the Language field. So /zh/blog/my-post and /en/blog/my-post are genuinely two pieces of content, not "UI-translated + shared body."

14. Caching strategy

Unlike the older "three-layer cache" complexity, the current setup simplifies to two layers + ISR:

┌─────────────────────────────────────┐
│  Layer A: unstable_cache + tags     │  Next.js 15 built-in
│  ├─ revalidate: 3600 (1h ISR)       │
│  └─ tags: ["notion", "content", "journey", ...]
└─────────────────────────────────────┘
              ↓
┌─────────────────────────────────────┐
│  Layer B: in-process Map<key,{data,ts}>│  my own lightweight layer
│  ├─ TTL: 1h                          │
│  └─ scoped to a single Node process  │
│     (clears on container restart)    │
└─────────────────────────────────────┘
              ↓
          Notion API

Layer A is Next.js's unstable_cache() — it covers RSC data caching and ISR page caching at once (same tag set).

Layer B is a simple Map, mainly to reduce duplicate Notion queries when Layer A hasn't warmed up yet (e.g., right after container restart) and multiple concurrent RSC calls hit the same query.

To invalidate, there's exactly one path: POST /api/revalidate with Authorization: Bearer $REVALIDATE_SECRET, and the handler calls revalidateTag("notion") to invalidate every cache tagged with "notion." SKILLS/operations/revalidate-site.mjs wraps this command:

npm run revalidate:site
# → POST $NEXT_PUBLIC_SITE_URL/api/revalidate
# → { "revalidated": true, "tags": ["notion", "content", "blog-posts", ...] }

15. API routes

Just three:

PathRole
/api/notion-image?url=...&blockId=...Proxies Notion's temporary S3 file URLs (avoids expiry / CORS / cache opacity)
/api/revalidateOn-demand cache invalidation (for scripts / Notion webhooks)
/api/posts, /api/productsList JSON (kept for potential client-side fetching, though all current views are RSC)

/api/notion-image sets caching:

"Cache-Control": "public, max-age=86400, s-maxage=604800"

Browser caches for 1 day, CDN / shared caches for 7 days.

/api/revalidate validates via Authorization: Bearer:

export async function POST(req: Request) {
  const auth = req.headers.get("authorization");
  if (auth !== `Bearer ${process.env.REVALIDATE_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  const tags = ["notion", "content", "blog-posts", "products", "journey", "about"];
  tags.forEach((tag) => revalidateTag(tag));
  return Response.json({ revalidated: true, tags, revalidatedAt: new Date() });
}

16. SEO and site indexing

Next.js server-renders pages + RSC, so SEO foundations are naturally solid. Detail pages use generateMetadata() to derive metadata from Notion:

export async function generateMetadata({ params }) {
  const { locale, slug } = await params;
  const post = await getBlogPostBySlug(slug, locale);
  return {
    title: post.title,
    description: post.summary,
    openGraph: {
      title: post.title,
      description: post.summary,
      type: "article",
      publishedTime: post.date,
      tags: post.tags,
      images: post.coverImage ? [{ url: post.coverImage }] : undefined,
    },
  };
}

metadataBase is set to https://yeagerzhao.com so all relative URLs resolve to absolute ones.

Plus:

FileRole
src/app/sitemap.tsWalks the four databases to generate the sitemap
src/app/robots.tsGenerates robots.txt

17. Docker + Nginx + Aliyun ECS deployment

The Dockerfile is the classic multi-stage standalone:

node:20-alpine (base)
    |
    v
deps: npm ci
    |
    v
builder: inject NOTION_* env → npm run build
    |
    v
runner: copy .next/standalone + .next/static + public/
       USER nextjs
       EXPOSE 3000
       CMD ["node", "server.js"]

The resulting image is ~200MB, light enough for the cheapest Aliyun ECS box (2 vCPU / 2GB RAM).

Port binding:

docker run -d \
  --name yeagerzhao-blog \
  --restart always \
  -p 127.0.0.1:3000:3000 \      # bound to localhost only — external traffic can't reach the container directly
  --env-file .env.local \
  yeagerzhao-blog

Nginx's job:

  • Listen on 80/443, terminate HTTPS (cert from certbot)
  • Reverse-proxy requests to 127.0.0.1:3000
  • Long-cache /_next/static/

A typical config:

server {
    listen 443 ssl http2;
    server_name yeagerzhao.com www.yeagerzhao.com;

    location /_next/static/ {
        proxy_pass http://127.0.0.1:3000;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    location /favicon.ico {
        proxy_pass http://127.0.0.1:3000;
        add_header Cache-Control "public, max-age=86400";
    }

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

deploy.sh automates "install Docker, install Nginx, build, docker run, write nginx config" — a fresh ECS goes from zero to live in about five minutes.

Deployment topology
Deployment topology

18. Day-to-day workflow

Writing a new blog post

# 1. create the local folder
mkdir -p "D:\...\YeagerZhao\content\blog\<slug>"
# add manifest.json + draft.zh.md + draft.en.md + images/

# 2. dry-run to see how many Notion blocks the markdown parses to
npm run publish:content -- --dir "...<slug>" --dry-run

# 3. real publish (writes Notion, Published=false)
npm run publish:content -- --dir "...<slug>"

# 4. review in Notion, flip Published to true

# 5. push the site
npm run revalidate:site

Publishing an album

# 1. prepare locally: D:\...\<album folder>\
#    ├── manifest.json    # type/slug/date/title/summary/feeling/place
#    ├── cover.jpg        # optional
#    └── xxx.jpg ...

# 2. see the upload plan
npm run publish:journey -- --dir "..." --dry-run

# 3. real publish (auto compress + watermark + OSS + Notion)
npm run publish:journey -- --dir "..."

# 4. flip Published=true and revalidate
node SKILLS/publish-journey-album/mark-journey-published.mjs --slug ...
npm run revalidate:site

Deploying code changes

# local
git add . && git commit -m "..." && git push

# server
ssh yeagerzhao.com
cd /opt/blog
git pull
docker build \
  --build-arg NOTION_API_KEY=... \
  --build-arg NOTION_CONTENT_DATABASE_ID=... \
  --build-arg NOTION_JOURNEY_DATABASE_ID=... \
  --build-arg NOTION_JOURNEY_PHOTO_DATABASE_ID=... \
  --build-arg NOTION_ABOUT_DATABASE_ID=... \
  -t yeagerzhao-blog .
docker stop yeagerzhao-blog && docker rm yeagerzhao-blog
docker run -d --name yeagerzhao-blog --restart always \
  -p 127.0.0.1:3000:3000 --env-file .env.local yeagerzhao-blog

Content updates need no redeploy — just the publish scripts + revalidate.

19. Key trade-offs

Why not host on Vercel directly?

Vercel is the smoothest option for Next.js, but this site needs to serve visitors inside mainland China, and self-hosting ECS + Nginx is meaningfully more reliable here than Vercel — plus I get cert and edge control. The cost is maintaining Docker and the server myself. For a personal site, that trade is worth it.

Why share one content table between Blog and Product?

When I published this very article, I tagged it as both Blog and Product types — the same body shows up at /blog/<slug> and /product/<slug>. If they were separate tables, I'd either maintain two copies or add a join layer. One table + a ContentType field keeps the logic simple.

Why does the album pipeline go through OSS instead of Notion?

Notion's image field returns temporary S3 URLs (expiring after ~30 minutes), totally unsuitable for albums — open a page and you get 404s. OSS gives public URLs + long cache + realtime image processing, exactly filling that gap. In the album flow, Notion just stores the OSS master URL (a stable string) and is no longer acting as an image host.

Why bother with invisible watermarks?

So I have leverage if someone steals a photo. Visible watermarks can be cropped or cv2.inpaint'd off in a second; a DWT-DCT-SVD frequency-domain watermark doesn't impair the image, survives JPEG re-encoding, resizing, and light cropping — and as long as the image isn't fundamentally altered, the string "YeagerZhao" decodes cleanly. For a personal photography + writing site, that's a zero-cost deterrent.

Why write NotionBlocks myself instead of using react-notion-x?

Bundle size + RSC, as covered in section 10. The cost is that I can't render every Notion block type (database views, synced blocks, AI blocks — those get ignored), but I never use them in my own writing.

Why no longer copy source images into a local archive?

The publish script used to copy each album's source images into archive/journey/<slug>-<timestamp>/. After 9 albums, that ate 1.1GB. The folder is essentially a redundant copy of source files — my originals live on E:\ anyway, so archive was just in the way. Now publish doesn't copy; if I need to re-watermark, I just point rewatermark-album.mjs at the original folder on E:\.

20. Limitations and next steps

What's still imperfect:

  • On-demand revalidation isn't wired to a Notion webhook: I have to run npm run revalidate:site manually after editing Notion. Ideally Notion would push to /api/revalidate directly. Notion's own webhooks are immature; this likely needs Zapier or a polling sidecar.
  • Layer B in-memory cache is per-process: the first request after a container restart hits Notion API; could migrate to Redis or SQLite for persistence.
  • No search / tag pages / archive: the blog list is just reverse-chronological — no tag filter, no by-year archive. Adding them is easy, but I want to think the URL structure through first.
  • The watermark only survives "screenshot + re-compress" level threats: heavy editing (large crops, color grading, CLAHE) can still lose it. A more paranoid setup would add a pixel-level perceptual hash as a last line of defense.
  • No comments / subscriptions: contact is just by WeChat QR or email. If I add comments later, it'll likely be giscus (GitHub Issues backend).

What might come next:

  • A source whitelist for /api/notion-image to prevent it from being abused as an open proxy
  • Reading progress bar + estimated reading time on article pages
  • "Chinese-English cross-linking" for blog content — currently zh/en are two independent rows, but the top-right language toggle just swaps paths bluntly instead of matching by slug
  • GitHub Actions for auto-deploy, replacing manual SSH

21. Wrap-up

The core design philosophy of YeagerZhao_Blog is: each layer does only what it's best at.

  • Notion for content creation (writing experience + collaboration + mobile)
  • Local Markdown + SKILLS scripts for content engineering (git versioning + replayable publishes)
  • sharp + invisible-watermark + OSS for image assets (compression + anti-theft + CDN)
  • Next.js 15 for page generation (RSC + ISR + i18n + SEO)
  • Tailwind v4 + CSS variables for the visual system (single palette + font strategy)
  • Docker + Nginx + Aliyun ECS for the runtime (encapsulation + reverse proxy + HTTPS)

The result is a personal site that's cheap to maintain, fast to read, visually customizable, light on content updates, and protective of its photos. It's not the most complex architecture out there, but it's the right one for a personal blog: modern enough, fast enough, and controllable enough.