Building a Personal Blog from Scratch: Technology Choices, Architecture, and Lessons Learned
Why Build My Own Blog?
There is no shortage of blogging platforms: CSDN, Juejin, SegmentFault, Zhihu Columns. Each has its audience and its problems—more ads, SEO outside your control, uniform designs, and data you don't own. More importantly, you can't publish long articles, short thoughts, and timeline events in one place. They end up scattered across platforms and formats.
Static site generators such as Hexo, Hugo, and Jekyll solve some of this: Markdown writing, Git-based management, and free hosting. Their limitations are also obvious. Every post goes through git commit → push → CI → deployment. Fixing a typo from your phone is practically impossible. There is no admin interface, live preview, or draft state, and tags are maintained manually in frontmatter.
WordPress goes to the other extreme: feature-complete but too heavy. PHP and MySQL feel excessive for a small personal site. Performance tuning, security patches, and plugin compatibility all add maintenance costs.
What I wanted was clear:
Own my content—my database, my data.
Write anywhere—log into the dashboard from any browser to write articles or post thoughts.
Markdown with live preview—the writing experience I find most comfortable.
More than a blog—long articles, short thoughts, timeline events, and more.
Chinese and English—without a bulky i18n framework.
Speed—fast initial loading, navigation, and search.
Control every line of code—both a personal creation and a test of my engineering skills.
Technology Choices: Tradeoffs at Every Layer
Next.js 16 App Router
Choosing Next.js felt almost inevitable. React has a rich ecosystem, and App Router's Server Components naturally suit content-heavy sites. Most pages are read-only and can be rendered on the server with no client-side JavaScript overhead.
The "use cache" directive in Next.js 16 was a key factor. It lets me declare caching at component granularity and use cacheTag for precise invalidation. I'll explain that mechanism below.
I also considered a traditional NestJS and React split, but it was too heavy for a personal project: two projects to maintain and lots of backend CRUD endpoints. Direct database-level access control and data operations were simpler and more efficient. (I unilaterally declare PostgreSQL the world's best database.)
Supabase(PostgreSQL + Auth + Storage)
One open-source BaaS solves three problems at once:
Database: PostgreSQL with PostGIS support; row-level security (RLS) handles access control in the database.
Authentication: GitHub and Google OAuth out of the box, with server-side cookie management through @supabase/ssr.
Storage: images go straight to Supabase Storage, without separately integrating S3 or OSS.
Compared with building an Express/Fastify backend, Passport.js authentication, and object storage, Supabase eliminates an entire backend project. Its generous monthly free allowances are also more than enough for a personal site.
Tailwind CSS v4 + SCSS
Tailwind makes component styling fast, while SCSS is better for global variables, complex selectors, and animation keyframes. I mix them: Tailwind handles about 80% of layout and component styles, while SCSS handles global variables, mixins, and modules that need finer control.
Custom i18n Instead of next-intl
next-intl is the most mainstream internationalization solution in the Next.js ecosystem, but it was too heavy for this project and hadn't adapted to Next.js 16's latest Cache Components feature. The site has only two locales, en-US and zh-CN, and a few hundred lines of translations. That didn't justify a framework.
So I wrote a lightweight translation function with fewer than 50 lines of core logic:
Namespace and dot-path key lookup (t("Navigation.posts") → "文章", meaning "Posts").
Support for {placeholder} interpolation.
t.rich(key, values) embeds React components in translated text, for example rendering <link>click here</link> as an <a> element.
Compared with adopting a full framework, this small amount of code is completely under my control, with no performance overhead.
Other Choices
Framer Motion: the homepage's typewriter and scroll-entry animations. CSS animations don't handle complex choreography.
CodeMirror: the dashboard's Markdown editor, much lighter than Monaco and friendlier to mobile devices.
Biome: one tool for linting and formatting instead of ESLint and Prettier, with simple configuration and fast execution.
react-markdown and remark plugins: react-markdown exposes the AST, allowing custom remark plugins for directive extensions.
For example, custom directives embed cards and references in articles:
:meta{url="https://music.apple.com/us/new"}
@startuml
aaaaaaaaa -> bbbbbbbbb : hello
bbbbbbbbb -> ccccccccc : hello
ccccccccc -> ddddddddd : hello
ddddddddd -> eeeeeeeee : hello
@endumlCustom remark plugins parse these directives into specific components rather than standard HTML. Compared with writing JSX or HTML directly in Markdown, this stays cleaner when switching renderers or exporting plain text.
:meta{url="https://music.apple.com/us/new"}
@startuml
aaaaaaaaa -> bbbbbbbbb : hello
bbbbbbbbb -> ccccccccc : hello
ccccccccc -> ddddddddd : hello
ddddddddd -> eeeeeeeee : hello
@endumlDiagram unavailable. Use Show code to inspect the source.
Project Design
Overall Architecture
@startuml
!theme plain
skinparam componentStyle rectangle
skinparam defaultFontSize 13
package "Browser" {
[Public pages\n(Home/Posts/Thoughts/Events)] as Public
[Admin interface\n(Dashboard)] as Dashboard
}
package "Next.js Server" {
[Page rendering\n(RSC)] as Render
[Page cache\n("use cache" + cacheTag)] as PageCache
[Data cache\n("use cache" + cacheTag)] as DataCache
[Webhook route\n/api/webhook] as Webhook
}
package "Supabase" {
database "PostgreSQL" as DB {
[posts / thoughts / events\n+ Related tag tables] as Tables
[RLS policies\n(is_admin)] as RLS
[DB Trigger\n(pg_net)] as Trigger
}
[Auth\n(GitHub / Google OAuth)] as Auth
[Storage\n(Images)] as Storage
}
' === Public page flow (two cache layers)===
Public --> Render : Page request
Render --> PageCache : 1. Check page cache
PageCache --> DataCache : Miss -> 2. Check data cache
DataCache --> DB : Miss -> 3. Query database
DB --> DataCache : Data
DataCache --> PageCache : Rendered result
PageCache --> Render : HTML
Render --> Public : Response (return immediately on a hit in either cache)
' === Admin editing flow ===
Dashboard --> DB : Direct database access\nCRUD (Client SDK)
Dashboard --> Storage : Upload images
Dashboard --> Auth : OAuth sign-in
' === Cache invalidation flow ===
DB --> Trigger : INSERT/UPDATE/DELETE
Trigger --> Webhook : pg_net HTTP POST\nNotify which table changed
Webhook --> PageCache : revalidateTag()
Webhook --> DataCache : revalidateTag()
DataCache --> PageCache : Invalidate both cache layers\nRebuild on the next request
' === Authentication flow ===
Auth --> RLS : JWT → app_metadata.role
RLS ..> Tables : Row-level access filtering
@endumlDiagram unavailable. Use Show code to inspect the source.
Project Structure
src/
├── app/[locale]/ # 页面路由(locale 前缀)
│ ├── (index)/ # 公开页面(首页、文章、碎碎念、事件)
│ ├── auth/ # OAuth 登录
│ └── dashboard/ # 管理后台
├── lib/
│ ├── client/ # 浏览器端 Supabase、主题、搜索
│ ├── server/ # 服务端 Supabase、缓存标签
│ └── shared/ # 共享服务层、i18n、配置工具
├── components/
│ ├── features/ # 业务组件(文章、搜索、标签等)
│ ├── shared/ # 共享 UI(主题切换、语言切换)
│ └── ui/ # 通用 UI 原语(Button、Modal 等)
└── types/ # Supabase 生成的类型 + 聚合类型Interesting Problems and Their Solutions
1. Caching: An Always-Fast Public Site and an Always-Fresh Dashboard
The public site and dashboard fetch data very differently:
The public site serves readers and needs the fastest possible responses. It caches rendered output with "use cache", serving readers directly from cache without querying the database, close to static-page performance.
The dashboard is an editing interface and must reflect changes immediately. It bypasses caching and queries the database directly, showing the latest result right after saving.
cacheTag and revalidateTag connect these strategies: edit data in the dashboard → a Supabase trigger notifies a webhook → revalidateTag invalidates the relevant cache → the public page rerenders on its next visit. This preserves public-site performance while automatically propagating dashboard changes.
Pure SSR requires a Vercel-to-Supabase database round trip for every request, which is slow. Pure SSG generates pages at build time and requires redeployment for new posts.
Next.js 16's "use cache" offers a third path: cache during rendering and invalidate precisely when data changes.
A component declaring "use cache" has its rendered output cached. The key is telling the cache when to invalidate. My approach is:
Page components and generateMetadata use cacheTag() with tags such as blog:posts, blog:summary, and blog:posts:${slug}.
A Supabase database trigger sends an HTTP POST through pg_net to the site's /api/webhook route.
The webhook maps the changed table to its cache tags and calls revalidateTag().
用户保存文章 → Supabase INSERT/UPDATE
→ DB trigger → pg_net HTTP POST
→ /api/webhook → revalidateTag("blog:posts")
→ 下次访问列表页自动重新渲染This is fully automatic: the author just writes, and the cache responds to data changes. No server commands are needed, and save handlers don't need scattered manual revalidateTag calls.
Two Layers of Caching: Pages and Data
That describes page-level caching. In fact, I use two layers:
Page cache: caches an entire page's rendered output. A hit returns HTML directly without even triggering a data query.
Data cache: data-fetching functions also declare "use cache". For example, fetchConfigs() is called by the homepage, layout, generateMetadata, and other places. With only page caching, a cache miss under high concurrency could query the same database data multiple times within a request. Data caching ensures that even simultaneous callers requesting the same data query the database only once.
Take configuration: the site's title, About Me content, and playlist live in configs and are read in at least four or five places. A data cache tagged blog:config is shared by all callers, minimizing database load. When the webhook calls revalidateTag, both cache layers invalidate together, preserving freshness.
(A personal site hardly has high concurrency, but I'm happy with the design itself.)
2. RLS: Move Authorization into the Database
Traditional authorization happens in the API layer: each endpoint checks identity and permissions. The problem is that forgetting a single endpoint creates a security hole.
This design exists because the dashboard accesses Supabase directly from the browser rather than through the Next.js server. It removes a hop for faster responses and prepares for future features such as comments and likes, which can use the same direct connection. RLS provides security without another API layer.
Supabase's RLS moves that logic into the database engine:
-- 公开用户只能看到 status='show' 的内容
CREATE POLICY "Public read posts" ON posts FOR SELECT
USING (status = 'show' OR is_admin());
-- 管理员可以写
CREATE POLICY "Admin insert posts" ON posts FOR INSERT
WITH CHECK (is_admin());is_admin() is a database function that reads role information directly from the JWT's app_metadata. Even if someone obtains the Supabase anon key, they still cannot access posts with status='hide': the database engine blocks access before the SQL executes.
The application layer doesn't need to write authorization checks. As long as the Supabase client uses the appropriate key—anon or service role—RLS takes effect automatically.
3. Internationalization: Enough Is Enough
A common mistake is to reach for the most complete i18n framework immediately. Most personal sites actually need only:
Language-specific paths, such as /en-US/posts/hello and /zh-CN/posts/hello.
Translations stored in JSON files.
Variable interpolation and simple rich text.
The custom getT function mentioned earlier has straightforward core logic:
const t = getT("IndexHome", locale);
t("latestPosts.cardTitle"); // "最新文章"
t("searchCount", { count: 5 }); // "找到 5 条结果"
t.rich("welcome", { // "欢迎,<link>点此</link> 查看"
link: (text) => <a href="/profile">{text}</a>
});The key is t.rich: it replaces <tag>text</tag> markers in translation files with React components. That handles links, bold text, and other rich content without putting HTML into translation files.
4. Global Search: Database Full-Text Search Is Enough
Site search has simple requirements: match a user's keywords against article titles and bodies, thought content, and event titles and descriptions.
Elasticsearch might be the first thought, but it is overkill for a few thousand articles. PostgreSQL's ILIKE and simple relevance ordering are sufficient:
SELECT 'post' as type, id, title,
ts_headline(content, plainto_tsquery(query)) as snippet
FROM posts
WHERE status = 'show'
AND (title ILIKE '%' || query || '%'
OR content ILIKE '%' || query || '%')Wrap this in a Supabase RPC function and call rpc('search_content', { query }) from the frontend. There is no separate search service, no index synchronization delay, and no additional deployment or maintenance cost.
5. A Modal System That Supports Stacking
Editing, creating posts, and confirming deletion all need dialogs. Opening a dialog inside another happens often, so I clearly needed a modal system that supports stacking.
Most component-library modals are too template-driven and difficult to customize. I wanted effects such as dimming the underlying dialog without hiding it completely, then restoring it when the child closes. I also wanted an unusual but interesting feature: anchor-based positioning. The public site and dashboard have different anchor locations, so dialogs appear in different places, visually connecting them to the button or area that triggered them.
Each modal has its own ID and z-index.
Stacking is supported: opening a modal inside another automatically dims the one below.
Closing follows stack order instead of closing every modal at once.
ModalProvider (Context)
├── Stack Manager (维护 modal 栈)
├── useModal() hook (push / pop / closeAll)
└── 自动处理 body scroll lock 和 backdropIt is essentially a simple stack distributed through Context, with no third-party library needed.
6. Three Layers of Supabase Clients
This is one of the cleanest architectural decisions in the project, in my opinion.
At first, I didn't think it was necessary. Separate server and client CRUD implementations seemed fine. As the project grew, duplicated code became hard to maintain, especially complex queries such as post lists joined with tags. I extracted shared services accepting an optional SupabaseClient, letting one set of queries serve both contexts.
Shared layer: a static public client used by default for read-only public-page queries.
Server layer: reads the cookie session through @supabase/ssr for authenticated server operations.
Client layer: a browser client for scenarios requiring a live session.
All CRUD logic lives in lib/shared/services/ and accepts an optional SupabaseClient. Passing different clients reuses the same code for public pages without privileges and admin pages with admin privileges. There is no separate copy of SQL for public and dashboard endpoints.
Summary
I planned the overall architecture before coding, and AI assistance made development fairly quick. Most refinement and optimization happened in spare time.
The title promises lessons from pitfalls, but honestly, starting lightweight and avoiding third-party libraries meant I encountered few real traps. Fewer dependencies meant fewer problems. The more interesting part was the design tradeoffs: what to choose, what to leave out, and why. My biggest takeaway is: the greatest luxury in a personal project's technology choices is using only what you need.
No microservices—a Next.js application is enough. No separate search service—PostgreSQL's full-text search is enough. No heavyweight i18n framework—a 50-line translation function is enough. No custom backend—Supabase's BaaS is enough.
Choosing just enough at each layer produces a system with low maintenance costs, good performance, and complete personal control. That may be the best part of building your own site: not reinventing wheels for its own sake, but making every wheel exactly the size you want.