Building the blog with Astro Content Collections and Markdown
I stream ambient music generated in real time on a modular synth as background music for working on Modular Synth Ambient, a YouTube channel.
Streaming is largely a side effect of generating my own work BGM, but I launched this blog so I can gather favourite archive streams as notes, introduce gear, and more.
For this very first post on the blog, I want to cover something unrelated to modular synths or ambient: the technical stack behind the blog.
Goals and highlights
- Try aube
- Dark mode / light mode
- Multiple languages
- Markdown-based content / Content Collections
- No ads1
- Publish free or at low cost
- Try
anime.js
Concrete tech stack
Frameworks and services used (versions reflect those at blog launch):
- Domain: Cloudflare Domains
- Hosting: Cloudflare Workers
- Static site generator (SSG): Astro 6.1.9
- CMS: Astro Content Collections
- CSS framework: tailwindcss 4.2.4
- Package manager: aube
- Database: Cloudflare KV
- Icons: Phosphor Icons
Rationale per service is summarized below.
Why Cloudflare Domains
Cloudflare’s registrar. Pricing is close to wholesale, so renewal stays very cheap.
Main reasons: hosting on Cloudflare Workers and using a .stream domain for about $5/year. With the yen weak, I wanted to keep costs down.
Why Cloudflare Workers
Again Cloudflare. For a static site it should still be free (as far as I know).
Cloudflare also offers Pages for static sites, but lately they steer static sites toward Workers, so I followed that.
Deployments are built locally and shipped via wrangler.
Why Astro / Content Collections
For content I considered headless CMS options such as Emdash CMS or Payload CMS, but I skipped a CMS because I wanted to ship quickly.
In practice:
- A rich WYSIWYG editor isn’t necessary when you manage everything solo
- With multilingual needs in the picture, CMS setup felt too heavy
Managing and rendering Markdown with Astro Content Collections looked fastest to wire up; I figured I could launch before I lost interest.
Why tailwindcss
At first I resisted long class strings and repeating the same utilities everywhere — but I’m a fan now.
The “verbose classes” issue doesn’t show up as strongly in Astro where styles live per component, and you get the benefit of not bouncing between distant HTML and CSS. Narrow-scope styling is also AI-friendly2.
Design tokens make layout and media-query work flow straight from thought to output. Once you internalize tailwind’s notation, it feels like it frees mental bandwidth.
Why aube
I’d been using pnpm / bun a lot lately, but the maintainer of mise shipped a new package manager called aube, so I’m trying it.
They pitch it as faster than bun and it really is. So far I haven’t hit a moment in development where speed bothered me. Adoption was straightforward too.
Why Cloudflare KV
When showing the latest YouTube videos, fetching the channel RSS from each visitor’s browser and rendering HTML dynamically turned out to be awkward because of CORS.
So I run a Worker that pulls YouTube RSS once per day on a cron schedule and stores it in KV. Visitors hit the Worker via a reverse proxy and load from KV to generate HTML dynamically.
Writes and payload size sit comfortably inside the free tier, so adopting KV was an easy choice.
Why Phosphor Icons
- SVG assets are distributed too
- MIT license
- Consistent design and quality across the set
- Easy to use with astro-icon
Astro Content Collections
With Content Collections you can manage content like a flat-file CMS using Markdown files.
Setup
During Astro setup you can pick a template that includes sample Markdown files:
# Set up with aube
aube create astro@latest
...
# In the wizard, when choosing a template,
# select `Use blog template`
tmpl How would you like to start your new project?
(o) Use blog template
...
# Install Tailwind with commands
aube install tailwindcss @tailwindcss/vite
# After setup, start dev like this
aube run dev
See the official docs Install Tailwind CSS with Astro as well—you’ll need things beyond install, such as placing global.css.
Directory layout
Because I planned multilingual routing with Astro’s i18n feature, the layout looks like this. Deployed URLs follow something like https://example.com/ja/***.
src/
├── assets/
│ ├── icons/ # Phosphor Icons SVGs
│ └── images/ # Site images
├── components/ # Astro components
├── content/ # Markdown lives here
│ └── blog/
│ ├── en/
│ └── ja/
├── layouts/ # Astro layouts
├── pages/ # Route files
│ ├── en/
│ │ └── blog/
│ └── ja/
│ └── blog/
└── styles/ # Tailwind global.css
Astro config
The i18n portion of astro.config.mjs is edited like this:
export default defineConfig({
...
i18n: {
defaultLocale: 'ja',
locales: ['ja', 'en'],
routing: {
// Include locale in URLs even for the default language.
// Set to false if you don't want that.
prefixDefaultLocale: true,
redirectToDefaultLocale: true,
},
},
...
});
Routing
Below is an example of routing against the default project template. For code that fits your own layout, generating it with an AI agent works fine.
---
import { type CollectionEntry, getCollection, render } from 'astro:content';
import BlogPost from '/src/layouts/BlogPost.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts
.filter((post) => post.id.startsWith('ja/'))
.map((post) => {
const [, slug] = post.id.split('/');
return {
params: { slug },
props: post,
};
});
}
type Props = CollectionEntry<'blog'>;
const post = Astro.props;
const { Content } = await render(post);
---
<BlogPost {...post.data} slug={Astro.params.slug!}>
<Content />
</BlogPost>
From here, tweak the samples generated at setup and shape the site the way you like.