A modern personal blog interface built with Astro, powered by the Ghost CMS Content API.
Live Demo · Documentation · English · 简体中文 · 日本語
About this repo — this is the source of my personal site (solitudera.com), open-sourced as a showcase and for reference. It is not maintained as a reusable template, and I'm not actively taking issues or pull requests. It's MIT-licensed, so you're welcome to look around or fork it.
- Highlights
- Screenshots
- Tech Stack & Architecture
- Run It Locally
- Self-Hosting & Content Guide
- License
|
Astro 5 static site + React islands Content is pre-rendered at build time; interactivity hydrates only where it's needed ( |
Typed Ghost CMS data layer A headless Content API client (retry + timeout) → typed adapter → cached, grouped posts, with runtime validation at the external-data boundary. |
|
Multi-language (zh / ja / en) Posts grouped across languages by tag/slug with a three-tier fallback; |
Hand-tuned motion A spring-driven post timeline and a viewport-bottom ambient progress bar (critically-damped rAF spring with velocity-aware glow), all |
|
OKLch dual-theme design system Light/dark tokens defined in a perceptually-uniform color space, wired into Tailwind v4 via CSS-first |
Engineered for confidence Strict TypeScript ( |
For the architecture, code reference, and testing guide, see DEVELOPMENT.md.
Astro 5 (SSG) · React 19 islands · Jotai · Tailwind v4 · Ghost CMS (headless) · shiki · motion · TypeScript (strict).
Data flows in one direction: Ghost → typed client → adapter → cached posts → pages (SSG) → presentational components. The full picture — project structure, the tag / i18n systems, and the testing strategy — lives in DEVELOPMENT.md.
Just want to explore the code? The fastest path uses the public Ghost Demo API — no account needed:
corepack enable pnpm # or: npm i -g pnpm
pnpm install
cp .env.example .env
pnpm dev # http://localhost:4321Then point .env at the Ghost demo:
GHOST_URL=https://demo.ghost.io
GHOST_CONTENT_KEY=22444f78447824223cefc48062
SITE_URL=http://localhost:4321Useful checks:
pnpm check(lint + format + typecheck) andpnpm test:run(unit tests). See DEVELOPMENT.md for the full command list.
The repo doubles as the real implementation of my site, so the full operator setup is here if you want to run it against your own Ghost instance or see how content is modeled.
Setup, environment, content publishing, multi-language & deployment
Edit .env with your own Ghost instance:
GHOST_URL=https://your-ghost-instance.com
GHOST_CONTENT_KEY=your-content-api-key-here
GHOST_VERSION=v5.0
GHOST_TIMEOUT=5000
SITE_URL=https://your-site.example.com
IMAGE_HOST_URL=
GOOGLE_ANALYTICS_TAG_ID=| Variable | Description |
|---|---|
GHOST_URL |
Base URL of your Ghost instance |
GHOST_CONTENT_KEY |
Ghost Content API key |
SITE_URL |
Public site URL for canonical and hreflang |
| Variable | Default | Description |
|---|---|---|
GHOST_VERSION |
v5.0 |
Ghost Content API version |
GHOST_TIMEOUT |
5000 |
Ghost request timeout in milliseconds |
IMAGE_HOST_URL |
- | Image host/CDN for the remote image domain allowlist (single URL or comma-separated list) |
GOOGLE_ANALYTICS_TAG_ID |
- | Google tag / GA4 Measurement ID (e.g., G-XXXX). Leave empty to disable |
CF_ACCESS_CLIENT_ID |
- | Cloudflare Access Service Token Client ID (if Ghost is protected by CF Access) |
CF_ACCESS_CLIENT_SECRET |
- | Cloudflare Access Service Token Client Secret |
- Log in to your Ghost Admin panel
- Navigate to Settings → Integrations
- Click Add custom integration
- Copy the Content API Key into your
.env
If your Ghost instance is protected by Cloudflare Access, configure a Service Token so the API can be reached:
- Create a Service Token in Cloudflare Zero Trust: Access → Service Auth → Service Tokens → Create Service Token; copy the Client ID and Client Secret.
- Add them to
.envasCF_ACCESS_CLIENT_ID/CF_ACCESS_CLIENT_SECRET. - Add a Service Auth policy to your Ghost Access Application selecting that token.
Note: this project only implements Service Token authentication — it sends
CF-Access-Client-Id/CF-Access-Client-Secretheaders when both variables are set (setting only one sends neither and logs a warning).
The following are Cloudflare dashboard policies — not implemented or read by this project; they merely let the Content API through if you have added extra protection:
- Bot Fight Mode: Cloudflare → Security → WAF → Custom rules → URI Path starts with
/ghost/api/content/, Action = Skip → all Super Bot Fight Mode rules. - Zero Trust Access Bypass: Zero Trust → Access → Applications → add
your-ghost-domain.com/ghost/api/content/*with a Bypass policy.
Use regular tags to classify posts. Special prefixes are recognized:
| Tag Prefix | Purpose | Example |
|---|---|---|
type- |
Post display type | type-article, type-gallery, type-video, type-music |
category- |
Content category | category-tech, category-life, category-design |
series- |
Article series | series-astro-tutorial, series-web-dev-basics |
| (no prefix) | General tags | JavaScript, React, Photography |
| Type Tag | Display Style |
|---|---|
type-article |
Standard article layout |
type-gallery |
Image gallery with carousel |
type-video |
Video player embed |
type-music |
Audio player embed |
| (default) | Default card layout |
| Route | Description |
|---|---|
/ |
Auto-redirects to user's preferred language |
/zh/ |
Chinese posts listing |
/ja/ |
Japanese posts listing |
/en/ |
English posts listing |
/zh/p/{key}/ |
Chinese version of an article |
/ja/p/{key}/ |
Japanese version of an article |
/en/p/{key}/ |
English version of an article |
Use internal tags (starting with #) in Ghost:
| Internal Tag | Purpose | Example |
|---|---|---|
#lang-{locale} |
Specify post language | #lang-zh, #lang-ja, #lang-en |
#i18n-{key} |
Translation group identifier | #i18n-intro-to-solitude |
In the Ghost Content API, internal tags
#xxxbecome slugshash-xxx.
Slug naming convention (reserved prefixes) — besides internal tags, the system can also derive a post's identity from the Ghost slug, using {locale}-{key}:
| Post slug | Resolves to |
|---|---|
ja-homeserver-8 |
locale ja, key homeserver-8 → /ja/p/homeserver-8 |
en-blog-project |
locale en, key blog-project → /en/p/blog-project |
Important:
zh-/ja-/en-are reserved slug prefixes. Any slug starting with a valid locale code plus a hyphen is treated as a multi-language post of that locale, even without#lang-*/#i18n-*tags — so don't give an ordinary (non-multilingual) post azh-…/ja-…/en-…slug, or it will be merged into a translation group with wrong/{locale}/p/{key}routes and hreflang. When a post also carries a#lang-*tag: language comes from the tag first (slug prefix as fallback); the translation-group key comes from the slug first (#i18n-*tag as fallback).
Creating a multi-language post — each language version is a separate post in Ghost, linked by a shared #i18n-{key} tag:
- Pick a translation-group key (e.g.
astro-guide) — it's used in the#i18n-astro-guidetag and the URL/{locale}/p/astro-guide. - Create the Chinese post: write the content, add tags
#lang-zhand#i18n-astro-guide(plus optionaltype-article,category-tech), publish. - Create the Japanese post (a separate post): tags
#lang-jaand the same#i18n-astro-guide, publish. - Create the English post (a separate post): tags
#lang-enand the same#i18n-astro-guide, publish.
The three become /zh/p/astro-guide, /ja/p/astro-guide, /en/p/astro-guide, switchable from the language switcher.
| Post title | Tags |
|---|---|
| "Astro 入门指南" (Chinese) | #lang-zh, #i18n-astro-guide, type-article, category-tech |
| "Astro入門ガイド" (Japanese) | #lang-ja, #i18n-astro-guide, type-article, category-tech |
| "Getting Started…" (English) | #lang-en, #i18n-astro-guide, type-article, category-tech |
Fallback: if a requested language is missing, the default language (Chinese) is shown; if that's also missing, any available variant is shown in LOCALES order (zh, ja, en) — this order is load-bearing (Japanese is preferred over English when Chinese is absent). A banner indicates the fallback.
This is a static site (Astro SSG) — pnpm build outputs to dist/, hostable on any static host (e.g. Cloudflare Pages). Set the same environment variables on your build platform as in your local .env: static generation happens at build time, so Ghost content is fetched and pre-rendered during the build and your credentials never ship to dist/.
MIT — see LICENSE. Feel free to fork and adapt.



