Hello, Astro!
Preface
About two months ago, I started thinking about whether, in addition to xLog, I should create another multilingual personal blog—one for longer pieces that can’t be finished in just a few lines. Although I migrated many of my posts to xLog a long time ago, I still feel I need a primary/backup setup between two sites, so that if one becomes inaccessible, I won’t lose access to my blog posts.
So I started mapping out my needs. When I first built a blog, I used WordPress and Typecho. These systems usually need to be hosted on a VPS or shared hosting, and the server cost takes up a big part of the expense. Later, even when using serverless services, because the database needs to keep an “always-on heartbeat connection”, the monthly bill was still relatively high for me. After I discovered Gridea, I realized I didn’t actually need a CMS—I just needed a “blog generator” that converts local articles into static pages. That way, I can edit posts locally and publish them to something like GitHub Pages when needed. Later, because Gridea didn’t offer enough customization, I moved to Hexo and used the Kratos : Rebirth theme. Coincidentally, while reading the theme author’s blog, I found xLog and moved my blog there as well.
Now, I want to make my blog accessible to more people. I also want to try designing the blog’s style myself, while keeping the old workflow of writing Markdown locally. So I summarized a few requirements:
- Convert Markdown into static web pages
- Support multilingual i18n
- Allow me to fully design the blog theme and styles
- Integrate plugins I’ve used before (e.g., Waline comments)
- Be mobile-friendly
- Support switching between light mode and dark mode
The first point is the most important and core requirement: I need the blog framework to convert Markdown files into corresponding HTML pages, like Hexo does. The second, fourth, and fifth points are important features, because I plan to face users of many languages (similar to my YouTube channel). I also want to keep using Waline because its i18n is pretty good, and it gives me more control over the general direction of comment content. As for mobile adaptation, in my view, people nowadays visit websites more from mobile than from PC. The third and sixth points are “nice-to-have” features. They’re lower priority and can be done gradually after the more essential items.
After defining the requirements, I found that mature site-building tools always had something that didn’t fit. For example, Hexo’s multilingual i18n: I read several articles, but still couldn’t understand how to combine the Chinese site and the English site into one. Or VuePress: the default theme meets most needs, but if I want to build a custom theme, it feels like it would take a long time to ramp up again. After going around in circles, my attention returned to Astro. The first time I encountered Astro was when I happened to see @liruifengv’s tweet, and then I went back to my old job—localization. While doing translations, I followed their tutorial and built their blog demo site, and I felt how fast Astro renders and hot reloads with Vite. Now, looking back at Astro’s documentation, I suddenly realized that it seems Astro can provide everything I need, and Astro’s blog template uses the card style I like. So I decided to extend Astro’s official blog template with pages, styles, and features I need, forming a blog template I like, and naming it Astro Blog Plus.
Structure and Features of Astro-Blog-Plus
Compared with Astro’s official built-in blog style, I made some adjustments to the original template’s directory structure to meet i18n requirements, and added a lot of page-level and functional extensions. Below I’ll talk about both the structure and the features.
Directory Structure of Astro-Blog-Plus
The structure of Astro-Blog-Plus is shown below. {lang} corresponds to language codes like en, zh-hans, etc.
The default repository already includes three languages: English (en), Simplified Chinese (zh-hans), and Traditional Chinese (zh-hant), with their pages, directories, and RSS files generated. In real usage, create the corresponding files and folders according to the languages you need.
│ astro.config.mjs //Astro’s config file
│ package-lock.json
│ package.json
│ tsconfig.json
└───src
│ env.d.ts
├───components
│ BaseHead.astro //common <head> content
│ BlogPostLicense.astro //license info at the bottom of posts
│ Footer.astro //site footer
│ FormattedDate.astro //format dates
│ Header.astro //header image and navbar
│ HeaderLink.astro //top navigation links
│ LanguageSelector.astro //language selector
│ MainBlogHead.astro //load different CSS for different pages
│ MobileMenu.astro //mobile menu
│ SinglePageHead.astro //same purpose as MainBlogHead.astro
│ ThemeIcon.astro //light & dark mode toggle icon
│ WalineComment.astro //Waline comment component
├───content
│ │ config.ts
│ ├───draft //drafts
│ └───{lang} //blog posts for each language
├───layouts
│ BlogPost.astro //post layout
├───locales
│ └───{lang}
│ friends.json //friend links config per language
│ translation.json //source texts and translations per language
├───pages
│ │ index.astro
│ ├───{lang}
│ │ │ about.astro //about me
│ │ │ archives.astro //archives
│ │ │ friends.astro //friends
│ │ │ index.astro
│ │ │ messageBoard.astro //message board
│ │ │ tags.astro //tag summary
│ │ │ [...slug].astro //post page
│ │ │ [page].astro //pagination
│ │ └───tags
│ │ [tag].astro //posts under each tag
│ ├───rss //RSS feed configs
│ {lang}.xml.js
└───styles
global.css
main-blog.css
single-page.css
-
components: shared components such as Waline comments, dark mode toggle, date formatting, etc. Similar to Vue.js and other frontend frameworks, these components can be imported and used directly in other pages.
-
content: the Content Collections directory, used to store Markdown/MDX files, and can be retrieved via Astro’s getCollection API by collection name.
-
layouts: shared layouts. Currently it only contains the “blog post” layout.
-
locales: i18n JSON data, including translations for header/footer and friend link info.
-
pages: various page files, a required directory for every Astro project. The
indexunderpagesis required. Since Astro uses file-based routing, each language folder usually represents pages for that language. -
styles: global styles and shared styles for specific page types.
New Features in Astro-Blog-Plus
Compared with the original blog template, Astro-Blog-Plus adds the following new features:
- Internationalization (i18n): I refactored the original
pagesandcontent, and introduced dynamic parameters in shared components. The language is determined from the URL path, and strings are loaded from the corresponding translation files. To enable i18n in an Astro project, add the following toastro.config.mjs:
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
// https://astro.build/config
export default defineConfig({
site: "https://example.com",
integrations: [mdx(), sitemap()],
i18n: { // i18n configuration
defaultLocale: "zh-hans", // default language
locales: ["zh-hans", "zh-hant", "en"], // supported languages
routing: {
prefixDefaultLocale: true, // default locale also has a URL prefix
redirectToDefaultLocale: false // do not redirect automatically to default locale
}
},
});
defaultLocale is the default language. When loading the site for the first time, Astro will redirect the page to the index page of the default locale. locales is the supported language list; we need to add corresponding folders under pages based on these locale names.
routing controls i18n routing. I set prefixDefaultLocale to true so that the default language also has a prefix in the URL, and set redirectToDefaultLocale to false so that visiting the site directly (e.g., https://example.com) will render the pages/index page rather than redirecting to a locale index page.
This allows me to transform pages/index into a custom redirect page: detect the user’s system language and then redirect to the corresponding language home page.
Next, add the language folders and translation files under the locales directory. After that, in every page and component that needs i18n, add code like below to detect the current language and replace strings using the translated values.
type LanguageCollection = "zh-hans" | "zh-hant" | "en";
// Get current language
const url = new URL(Astro.request.url);
const lang = url.pathname.split("/")[1] as LanguageCollection;
// Dynamically load translation files
import translationsZhHans from "../locales/zh-hans/translation.json";
import translationsZhHant from "../locales/zh-hant/translation.json";
import translationsEn from "../locales/en/translation.json";
const translations = {
"zh-hans": translationsZhHans,
"zh-hant": translationsZhHant,
en: translationsEn,
}[lang];
// Define translation function
const t = (key: string, params: { [key: string]: any } = {}) => {
const keys = key.split(".");
let translation: any = keys.reduce(
(acc, k) => (acc && (acc as { [key: string]: any })[k]) || null,
translations as { [key: string]: any }
);
// If translation doesn’t exist or isn’t a string, return the key
if (typeof translation !== "string") return key;
// Replace placeholders with actual values
Object.keys(params).forEach((paramKey) => {
const placeholder = `{{${paramKey}}}`;
translation = translation.replace(
new RegExp(placeholder, "g"),
params[paramKey]
);
});
return translation;
};
-
Integrate Waline comment system: Waline consists of a client and a server. I deployed the server on Vercel following the official tutorial. The client
-
Add a “Friends” page:
-
Add pagination to the home page:
-
Add pinned posts: Compared to the original post properties, I added a new
topproperty inconfig.tsto represent whether a post is pinned. The larger thetopvalue, the higher the pin priority. Then, during pagination, I split posts into pinned and non-pinned posts, sort pinned posts bytop, sort other posts bypubDate, and finally concatenate them into apostsarray for pagination. The code is as follows:
export async function getStaticPaths({ paginate }: { paginate: PaginateFunction }) {
const topPosts = (await getCollection("zh-hans")).filter(
(post) => post.data.top !== undefined && post.data.top > 0 // distinguish pinned posts
).sort(
(a, b) => (b.data.top ?? 0) - (a.data.top ?? 0)
);
const otherPosts = (await getCollection("zh-hans")).filter(
(post) => post.data.top === undefined || post.data.top === 0
).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const posts = topPosts.concat(otherPosts);
return paginate(posts, { pageSize: 5 });
}
Then I added a <div> to mark pinned posts, and set classes on it so that pinned posts can be displayed differently. The code for this part is as follows:
CSS:
.isTop0 {
display: none;
}
.isTop1 {
display: block;
position: absolute;
top: 0;
right: 0;
background-color: #b692c2;
color: #fff;
padding: 0.5rem;
z-index: 5;
width: 5%;
text-align: center;
border-radius: 0 12px 0 0;
}
.dark .isTop1 {
background-color: #333;
}
HTML:
<li>
<div class=`isTop${post.data.top}`>TOP</div> // this line adds the TOP label
<a href={`/${lang}/${post.slug}/`}>
<img
width={720}
height={360}
src={post.data.heroImage}
alt=""
/>
<h4 class="title">{post.data.title}</h4>
<p class="date">
<div>
<FormattedDate date={post.data.pubDate} />
</div>
</p>
</a>
</li>
Final Words
This time, building my blog based on Astro’s blog template, I can clearly feel that I’ve improved in mobile adaptation, CSS layout, and JavaScript usage. I also gained a deeper understanding of Astro’s features and Astro APIs. The fastest way to get started with a framework is to read its documentation, combine it with AI tools like ChatGPT to help understand some APIs and structure, and then try to implement your ideas step by step. Watching the blog gradually take shape and become more and more complete still feels very satisfying!
Unless otherwise stated, all articles on this blog are licensed underCC BY-NC-SA 4.0license. The author reserves all rights. Please credit the source if you wish to reprint.