How I Built YeagerZhao Blog: From Local Writing to a Bilingual Website
When I started this website, I wanted a place of my own for projects, articles, and photographs from my travels. The first questions were visual: where the portrait belonged, whether navigation covered the title, and how photographs should be arranged. Publishing bilingual articles made another path matter: getting local drafts into Notion, keeping images available, and making edits appear on the website.
I now keep Chinese and English drafts alongside their source material locally. Publishing tools write the body to Notion and upload illustrations to OSS; Next.js produces a page from its language and Slug. This article follows one publication through that path, then explains the responsibilities and the checks that still require human attention.
The site is at www.yeagerzhao.com. First published on September 12, 2026; last checked on October 13, 2026. This revision updates the text and figures through Notion. Code changes explicitly marked as local review have not yet been pushed to Git or deployed. The diagrams explain responsibilities; they are not deployment-monitoring screenshots.
Designing around the writing
Different Content Needs Different Space
The site has five entrances: Home, Blog, Product, Journey, and About. Blog gives me room to explain a problem, its background, and the work involved. Product is closer to a project introduction: what it does, where it stands, and where to try it. Journey gives photographs and videos more space, with text adding context. About holds my introduction and experience.
Those are different reading experiences. A project card should be easy to scan. A long article needs a useful table of contents and comfortable paragraph spacing. A photograph needs room to be seen. One universal title-and-summary card would be convenient to implement, but it would not present everything the way I wanted.
The editing process mattered too. I wanted to work in a local folder, discuss a Chinese draft with AI, and then prepare an English version. Screenshots, unfinished paragraphs, and rough ideas could stay together. Once the material was ready, Notion would manage it for the website.
For a small text-only blog, Markdown or MDX with a static site could be enough. I chose Notion because I also wanted an accessible editing interface. OSS gave the images independent, stable addresses. Those requirements came with extra API, image-processing, and cache-management work.
The useful distinction is between three kinds of change: publishing text, replacing image resources, and deploying interface code. They meet when someone opens a page, but each has its own completion criteria.
Following an article from local files to the website
Following one publication from start to finish
For an example Slug, my-next-article, I prepare draft.zh.md, draft.en.md, manifest.json, and images, with published=false. A dry run checks local files and conversion. Publishing creates two private Notion drafts. I check both beginnings and endings, table rows, and images before making the records public and invalidating list and detail paths.
npm run publish:content -- --dir "<article-directory>" --dry-run
npm run publish:content -- --dir "<article-directory>"
npm run revalidate:site -- --paths /zh/blog,/en/blog,/zh/blog/my-next-article,/en/blog/my-next-articleI then visit both /zh/blog/my-next-article and /en/blog/my-next-article. A successful command, a visible Notion page, and an updated public website are separate results to verify. Saving a note does not make Notion push it to this site, and OSS does not tell a page to replace its image.

Solid paths represent requests or writes; dashed paths represent responses or invalidation. The author and AI prepare material locally. The publisher executes repeatable writes; AI is not a required website runtime service.
Settle the Chinese Draft First
My writing workspace sits outside the code repository. Each article has a folder containing Markdown, screenshots, and diagrams; a separate archive preserves backups. Content and code can be versioned independently, and private material is less likely to enter Git by accident.
YeagerZhao/
content/
blog/
yeagerzhao-blog-architecture/
draft.zh.md
draft.en.md
manifest.json
images/
product/
archive/I start with what the Chinese article needs to explain: its question, the reader's background, and where an illustration would help. Images follow the explanation. Once the Chinese draft is settled, English carries the same facts and limitations without requiring sentence-by-sentence translation.
This part of the process needed considerable adjustment. Asking AI to describe the entire project can produce a technically crowded article with no clear emphasis. Code can show the frameworks used, but it cannot explain why I wanted a page changed or which publishing problem mattered to me.
I have now put those preferences into yeagerzhao-writing. Notes, screenshots, actual problems, and, when useful, a spoken explanation come first. AI organizes the main argument, drafts the Chinese article, and checks facts separately from expression. Saying that I added caching is not enough: the article should explain where the delay occurred, which layer changed, and what remains unresolved. Without measurements, there should be no impressive-looking speedup percentage.
I still need to approve personal judgments. AI can propose an explanation, but a sentence about why I chose something must reflect my reasons. English follows the completed Chinese account. Style rules will continue to evolve from edits I approve, while illustration, uploads, and revalidation stay with separate tools. This reduces repetitive work; it does not make a topic prompt sufficient to produce a trustworthy article automatically.
manifest.json stores metadata outside the body: type, Slug, date, tags, and the titles and summaries in both languages. New work should normally remain unpublished during review. A simplified example looks like this:
{
"type": "blog",
"slug": "my-next-article",
"date": "2026-09-12",
"tags": ["Next.js", "Notion"],
"published": false,
"cover": "images/cover.png",
"zh": { "title": "下一篇文章", "summary": "文章的中文摘要。" },
"en": { "title": "My Next Article", "summary": "A short description." }
}Let the Publishing Tool Handle Repetition
I begin with a dry run. It reads the manifest and both Markdown files, attempts conversion, and checks local image references. This catches missing files and some formatting problems. It does not replace checking the live schema or reviewing technical claims.
npm run publish:content -- --dir "<article-directory>" --dry-run
npm run publish:content -- --dir "<article-directory>"@tryfabric/martian converts Markdown to Notion blocks. Additional processing handles image URLs and code-language names. A fence labeled text, for example, needs to become a language enum Notion accepts. A table needs rows and cells, not a paragraph that merely looks like a table.
Local article images go to OSS and become external image blocks in Notion. Existing public HTTP(S) image URLs remain unchanged. This differs from the original proposal to upload every article image directly to Notion. Native Notion images are still supported by the website, but local publishing currently uses OSS.
The tool finds the corresponding Chinese and English pages. The local publisher now separates the modes: --overwrite updates metadata while preserving the body and cover, and --replace-content replaces the body with a recovery snapshot. Creating, editing metadata, and replacing text are distinct operations. Published defaults to false. Explicitly setting it to true in the manifest makes the records eligible for public website queries.
A Successful Request Is Not the Whole Check
Replacing a Notion body is not a database transaction. Blocks are written in batches. A failed request can leave one language finished and the other incomplete. An earlier update of this long article was interrupted and required snapshots and read-back checks to recover.
The local publisher now snapshots page properties and the recursive body, appends replacement blocks, and reads them back before retiring the previous blocks. On failure it attempts cleanup and restoration. This is not a transaction across the two language pages. I still keep the old draft and inspect what actually reached Notion: the beginning and ending, image blocks, table child rows, and both language versions. Cache invalidation and a visit to the public URL come afterward. A command exiting without an error is only part of that verification.
Archiving still has limits. The local pull tool now exports properties and the complete recursive block tree into timestamped snapshots. Images remain URL references, not an offline asset bundle. Markdown export and conflict handling for local and online edits remain unfinished; these JSON snapshots are not one-click bidirectional synchronization.
One Next.js application and four content sources
Separate Responsibilities, Not Services
There is no independently deployed backend for this site. Most server work consists of querying published records, reading page content, generating pages, invalidating caches, and proxying certain images. Next.js App Router can handle those tasks.
The backend still exists; it lives in the same project as the frontend. Notion credentials stay on the server, away from presentation components. This leaves one fewer service to maintain and avoids making server components call the site's own JSON API just to retrieve data they can access through shared functions.
The project currently uses Next.js 15.5.15, React 19.1, and TypeScript 5. Tailwind CSS v4 works alongside the site's CSS variables. next-intl handles language routing, the official Notion SDK handles content access, and local publishing tools handle Markdown conversion and OSS uploads. These are the project's versions, not a universal recommendation for a new application.
After several rounds of reorganization, these are the main areas I work in:
src/
app/ Routes, metadata, loading boundaries, API entry points
[locale]/ Chinese and English routes
api/ JSON, revalidation, Notion image proxy
server/
notion/ Queries, field mapping, page content
cache/ Cache lifetimes, tags, invalidation
images/ Image proxy
ui/
tokens/ Color, typography, spacing, motion variables
components/ Shared buttons, navigation, cards
views/ Home, articles, projects, albums
styles/ Layout and article typography
effects/ Hero and shared effects
i18n/ Locale routing configuration
lib/ Image URL and video-link helpers
types/ Shared data types
SKILLS/ Local writing, publishing, operations toolsRoutes identify the requested content, server functions prepare the data, and the UI presents it. Changing a Notion property leads me to queries and mappers. Changing an article's panel color leads me to styles and tokens. Folder names do not enforce isolation on their own: imports and credential boundaries still have to be correct.
Following an Article Request
Take /zh/blog/yeagerzhao-blog-architecture. The zh segment identifies Chinese; the final segment is the article's Slug. The brackets in app/[locale] represent a dynamic parameter. For an English request, that parameter becomes en.
The page calls getBlogPostBySlug for metadata, including the title, date, tags, and Notion page ID. It then uses that ID to retrieve the body blocks. ArticleDetailView arranges the page, while NotionBlocks renders the body. Product details follow a similar path with a different content type.
Article URL
-> locale + slug
-> query Content metadata
-> obtain Notion page ID
-> retrieve the body block tree
-> arrange the article and render its blocks
-> return the pageRaw Notion responses are awkward presentation data. A title may contain several rich-text fragments, tags arrive as multi-select objects, and images have different source types. Mappers turn those structures into strings, arrays, and explicit types so that each component does not have to interpret the same JSON again.
One configuration detail deserves attention: the environment variable is still called NOTION_CONTENT_DATABASE_ID, but the SDK 5 query path uses dataSources.query and expects a data source ID. Do not assume the ID copied from a database page URL is interchangeable. The integration also needs access to the relevant source. Notion explains the distinction in its database guide.
Articles and Projects Share Content
The early plan assigned separate databases to articles, projects, profile information, experience, interests, and social links. It looked orderly, but it also multiplied configuration and repeated similar title, date, and language fields. I eventually reduced it to Content, Journey, JourneyPhoto, and About.
Articles and projects share a title, summary, date, tags, cover, and body. Both therefore live in Content, distinguished by ContentType: blog or product. A Chinese article list filters for blog, zh, and Published. A detail lookup additionally identifies the Slug.
The body belongs in the Notion page itself. Properties hold what the website needs for finding and presenting that page. I can filter records in the database, then open one and edit it like a regular note.
| Properties | Website purpose |
|---|---|
| Title, Summary | Title and list preview |
| ContentType, Language, Slug | Section, language, article address |
| Date, Tags | Date ordering and labels |
| Published, Featured | Public visibility and homepage selection |
| CoverImage | Cover reference |
| ProductUrl, Status | Project link and state |

This screenshot contains older data; its visible columns should not be treated as a current schema specification. It illustrates the shared database rather than a set of fields to copy literally.
A project introduction does not have to repeat its accompanying blog post. Product can explain what is available and where to find it; Blog can explain the design process. The shared data structure mainly reduces backend maintenance.
Connecting Chinese and English
Interface language and article language are separate concerns. Changing a navigation label from Chinese to English does not create an English article.
Each article has two Content records with the same Slug and different Language values, zh and en. Publishing identifies records by ContentType + Slug + Language; there is no additional TranslationKey. Titles can change, but published slugs should remain stable unless redirects are handled separately.
Notion does not automatically enforce uniqueness across those properties. An existing-record lookup is not a database uniqueness constraint. Before publication, the type, Slug, and language should still resolve to exactly one record. An empty English record is not a completed translation.
Albums and About Keep Their Own Structures
A Journey record represents an album, Vlog, anime edit, or travel note. It stores the title, date, place, cover, reflections, and video URL. Individual album photographs live in JourneyPhoto, related to the Journey record. The index can load covers and basic information without retrieving every photograph; the album loads its own images when opened.
Journey currently sorts by Date descending. Photographs sort by Order and then Name ascending. This differs from the early name-only proposal, so the queries and publishing tools are the useful reference. Chinese and English albums can point to the same OSS image files without uploading translated duplicates.
About combines profile, timeline, and highlight records through SectionType. Queries filter by language and Published, sort by Period, and map the records into page sections. Links to Bilibili, Xiaohongshu, CSDN, and GitHub change infrequently and remain in site configuration rather than a separate SocialLink database.
Four databases are sufficient for the present content. Before adding another field, the practical questions are whether the website uses it and whether the publishing tool can fill it reliably.
The local data-source wrapper now follows pagination cursors for lists, photos, and Slug enumeration. Body blocks already had their own pagination and recursion. This change needs a website deployment; updating this article does not deploy query code.
Separate paths for illustrations and photography
Article Images Need Stable References
Screenshots and diagrams support the text. When they already exist locally, publishing puts them on OSS, Notion holds the address, and the browser retrieves the image. The page server does not have to forward every image byte, and the files do not depend on a particular website build.
Images uploaded through the Notion editor are different. Their API file URLs expire; retrieving the block again supplies a fresh URL. The relevant value is expiry_time, not an assumed lifetime for every file. Notion documents this in its file retrieval guide.
The site's /api/notion-image route handles those sources, checking the host, response type, and size, and attempting a URL refresh when a block ID is available. Cache headers reduce repeated transfers. A cold request still waits for the upstream response: fetching and caching on first view is workable, but does not make first access instantly fast.
Image replacement also has its own cache problem. Overwriting an OSS object at the same URL may leave browsers showing the old file. Invalidating Next.js data does not erase their image caches. The publisher now includes a content hash in object names. Identical bytes reuse a URL; changed bytes produce a different URL, and different images sharing a basename no longer overwrite one another. Notion references are updated while previous URLs remain available for recovery.
Process Photography Before Making It Public
Journey requires more care. Camera originals and editing projects remain local; public OSS receives processed display masters. The tools apply orientation, resize the pixels, remove original metadata, and process watermarks before upload.

Orientation must be applied before metadata is removed, or a portrait photograph may lose the information needed to rotate it correctly. Removing EXIF and GPS is not a visual privacy review either. House numbers, license plates, and private information on a photographed screen remain in the pixels unless separately addressed.
The current processing budget is roughly eight megapixels. Compared with a single long-edge limit, that preserves more detail in panoramas. A 12000 by 2000 image reduced to a 3500-pixel long edge becomes about 3500 by 583. An eight-megapixel budget produces roughly 6928 by 1155. Both preserve aspect ratio; the latter leaves more vertical detail in a wide frame.
scale = min(1, sqrt(target pixel count / original pixel count))
new width = original width * scale
new height = original height * scaleThis calculates dimensions, not file size. Texture, encoding quality, and watermark processing all affect the final bytes. Eight megapixels is the current trade-off, not a promise that every image will have the same size. Photography publishing also depends on local sharp and Python environments; starting the website does not prove those tools are available on a new computer.
The watermark tool embeds the text YeagerZhao and provides a decoding check. It adds an identifying signal, but recovery after cropping or compression is not guaranteed. The meaningful check is the result after actual image transformations, rather than whether the script exited successfully.
Disabling right-click is not my protection strategy. A browser that displays an image has access to its pixels; screenshots and direct requests cannot be prevented by removing a menu. Keeping originals private, controlling display size, retaining creation records, and testing the watermark are more practical measures.
A Cover and an Album View Need Different Sizes
Once uploaded, images are selected according to their role. Small covers need less detail than album previews. Enlarged viewing can request the display master. src/lib/oss-image.ts centralizes three presets:
| Context | Current processing |
|---|---|
| thumb: list cover | 25% dimensions, quality parameter 70, WebP by default |
| preview: album image | 40% dimensions, quality parameter 78, WebP by default |
| full: enlarged view | The processed display master without another transformation |
Full does not mean camera RAW or the editing project. It is the largest public display version. OSS processing parameters derive thumb and preview without requiring three manually maintained uploads. For example, a preview can use x-oss-process=image/resize,p_40/format,webp/quality,q_78. The helper adds transformations only to recognized OSS sources. OSS documentation describes the image-processing service; object storage, image transformation, and a separately configured CDN should not be conflated.
Journey can then behave more like a gallery. The index organizes work by date, emphasizes covers, and reveals title, month, and type on interaction. Search helps locate a place or keyword. Inside an album, a narrow text column leaves most of the space to photography. The album component handles navigation, keyboard controls, position indicators, and enlargement. Vlogs and anime edits retain Bilibili or YouTube URLs rather than uploading video files to the photography store.
The local album implementation mounts only the current and adjacent photographs and adds focus entry, Tab containment, and restoration after closing the lightbox. These changes are awaiting local review and deployment; the public gallery still uses the previous implementation.
Rendering complete, readable articles
Fetching and Rendering Must Agree
A Notion page is a tree of blocks. Paragraphs, headings, code, images, and tables are different nodes, and some contain children. Fetching only the first batch or rendering only top-level nodes can appear fine for a short note while losing parts of a longer article.
The body reader follows pagination cursors for top-level blocks, then retrieves children recursively with a depth limit of four. The local implementation also limits block-request concurrency. Rendering maps nodes to corresponding HTML: paragraphs to p, consecutive list items to ul or ol, images to figure, and code to pre and code.
The earlier missing tables were a structural problem of this kind. A table block describes the table, while its table_row children hold the cells. Rendering only the parent cannot produce a complete table. What the reader fetches and what the renderer expects need to be checked together.
The custom renderer covers common text, inline formatting, lists, quotations, toggles, images, tables, and several media types. It still has limits. Files, bookmarks, and child pages may appear as links or references. Equations, synced blocks, and database views are not equivalent to reproducing the full Notion interface. Code retains its text and language identifier, but a language class alone does not provide syntax highlighting.
A custom renderer makes the HTML, styling, and interactions easier to adapt to this site. It also makes checking new block types my responsibility. Handling the common article types well is a more realistic goal than promising to reproduce the entire editor.
Give the Body Priority, Then Place the Contents
Long articles magnify layout problems. I encountered content sitting too close to the top, a fixed navigation bar covering headings, an overlong contents panel, and a body-plus-sidebar arrangement that felt off-center. Those need shared spacing and layout rules, not an extra blank line in each article.
The article layout now treats the body and contents as one composition. Dates and tags occupy separate lines in the rail; summaries remain in external cards and metadata. The contents panel follows headings and tracks the active section, with a height designed around roughly ten lines rather than growing indefinitely. Articles and albums share a simple text-and-left-arrow return control.
This article also exposed a CSS issue. Paragraph spacing had been set, then overridden by a later margin: 0, making consecutive paragraphs look cramped. The fix uses 16px body text, a 1.85 line-height, 18px between paragraphs, and 24px around images, quotations, and code. These values live in shared typography tokens and were checked against computed styles on the public site.
Structure matters beyond CSS. Giving every implementation detail its own top-level section makes readers repeatedly enter a new topic without a clear overall direction. Related questions now belong in larger chapters, with subheadings marking progress and paragraphs explaining causes and results. Code and tables remain where they help, rather than turning the whole article into a configuration manual.
Shared Visual Rules Reduce Repeated Work
The site began from the full YeagerZhao Design System HTML, then developed into tokens, components, views, and styles. Variables hold colors, fonts, spacing, radii, and motion timing. Related components share rules. Article panels and homepage sections such as recent projects use a shared surface-color convention, reducing the number of places to change later.
Newsreader is the main English typeface. Spline Sans Mono handles code and monospaced labels; ordinary Chinese text uses system fonts. The homepage greeting retains a Source Han Serif-style face, with the supporting line using the corresponding sans-serif resource. Library font files are self-hosted, avoiding a runtime dependency on a third-party font service. System Chinese fonts still vary by device, so this does not imply identical glyphs everywhere.
The Hero background, scroll reveals, and hover feedback also share logic where appropriate. The homepage can have more motion while the article stays quiet. Consistency helps future changes; it does not require every page to be equally animated.
The local reading layout now puts the complete title, date, and tags above the body and removes a matching duplicate first heading at the rendering boundary. The rail focuses on the table of contents, with full chapter names, expandable subsections, and shareable URL fragments. Mobile readers get a collapsible contents section. Subtle watercolor layers and grain sit behind opaque reading surfaces outside the Hero. List motion is reduced; the Hero keeps its visual treatment while pausing work offscreen, in background tabs, and for reduced motion. These interface changes are local code awaiting review; this content release does not deploy them.
Keeping content updates and code deployments consistent
Know what each cache stores
A slow article click and an unchanged article after a Notion edit involve different paths. One may be waiting for the source, generation, or images; the other may still use old page data, query results, or browser route state. The 3600-second default is a revalidation policy, not an hourly job that proactively synchronizes every article.
| Layer | Stored object | Update boundary |
|---|---|---|
| Page ISR | Generated page result | Path revalidation |
| Next data cache | Notion query and body tree | Cache-tag invalidation |
| Browser Router Cache | Visited or prefetched route data | Navigation or refresh |
| HTTP image cache | Image response for a URL | Resource version or its own invalidation |
The previous production query path also has a process-local Map, and its refresh endpoint invalidates all defined tags. The local revision removes that Map and scopes explicit Blog, Product, Journey, and About paths to their content-domain tags. Home or a default site refresh still invalidates every domain. This is domain-scoped, not per-article precision, and an already open browser may need navigation or refresh. Next.js 15 documentation explains the cache interface; behavior depends on how the project combines it.
Prefetching and visible feedback
I once wanted Home to warm all other article bodies, but those background reads compete for network capacity and Notion requests. Primary navigation uses Next Link. Blog and Product lists already use intent-based prefetching; the local revision applies the same rule to Home detail links: pointer entry, keyboard focus, or touch arms a short delay. Leaving a card cancels an untriggered timer.
Loading boundaries provide click feedback; they do not prove that the body has arrived. Evaluation separates feedback, readable text, and completed key images, with cold and warm caches distinguished. Production is the useful prefetching environment; development clicks can include compilation. See Next.js 15 prefetching documentation. JSON list endpoints remain available, while server components share query functions rather than calling their own HTTP API.
Code deployment and content publication
GitHub pushes trigger Vercel builds for code changes, including layout, queries, and interaction. Content publication writes the body to Notion and files to OSS, then invalidates caches and checks both public language URLs. Editing text does not require deploying interface work that is still under review.

This figure shows the current responsibilities without mixing the earlier ECS proposal into the main topology. The main domain, www alias, and configured site URL need to agree. Cloudflare proxy and cache settings require separate verification; a reachable domain does not establish the complete infrastructure configuration.
Local production checks use npm run build and npm start. Request failures propagate to an error boundary, distinct from a successful query with no matching article. Caching a failure as an empty list would obscure that distinction. Static-generation concurrency has also been reduced locally to avoid concentrated CMS requests.
Deployment needs Notion credentials, data source IDs, the site URL, and a revalidation secret. OSS upload credentials, originals, and writing directories stay local. Metadata, Open Graph, sitemap, and robots describe pages and make them discoverable; they do not guarantee rankings. Local changes also connect CMS SEO and cover-description fields to the output, generate alternate-language links only for existing translations, and stop using sitemap-generation time as a content modification time.
What works now and what comes next
The website now holds the content I originally wanted to keep: articles, projects, photography, and personal information. I can prepare Chinese, English, and images locally, then let Notion and the website present them. Interface changes do not require moving all the text again. Content and presentation have reasonably independent maintenance paths.
The costs are equally real. Notion's editing convenience requires queries, field mapping, pagination, and cache handling. A custom renderer needs checks for different blocks. Independent image storage brings URL versions, privacy, and delivery sizes. The maintenance has not disappeared; it is easier to locate.
The local revision adds publication snapshots and read-back, query pagination, content-hash image URLs, recursive JSON archiving, and reading-interaction fixes; website code still awaits review and deployment. Next comes validating recovery, adding offline assets and Markdown export, and handling conflicts between local and online edits. Automatic webhooks still require event validation, deduplication, and failure recovery before they count as finished.
My completion criteria are now concrete: both languages contain the full article, images open, a recoverable local version exists, and the next edit has an obvious starting point. Those are the things I want the next changes to improve. The pages can keep evolving; reliable content is what makes the website worth continuing to use.