I wanted a personal site that felt like a printed thing — an editorial poster, not a SaaS landing page — and I wanted to ship it as flat HTML I fully control. So itahir.com is a static Astro build, bilingual (English at / and Azerbaijani under /az/), with a zine/editorial identity: warm paper background, ink-black rules, one rust accent, Space Grotesk for display and JetBrains Mono for the small technical type. This post is about my choices building it and the small things that bit me, not a tour of Astro.
Static output, on purpose
The whole site is a static build. No server runtime, no database, nothing to keep alive at request time. My astro.config.mjs is deliberately tiny:
export default defineConfig({
site: 'https://itahir.com',
build: { format: 'directory' }, // /az/ → /az/index.html
compressHTML: true,
});
format: 'directory' is the one line that matters for a bilingual site. It emits /az/index.html instead of /az.html, so the Azerbaijani home lives at a clean /az/ URL and every relative thing resolves the way a reader expects. compressHTML strips the whitespace out of the output, and site gives Astro the absolute base it needs for canonical and hreflang links.
The deploy is just as boring, which is the point: astro build produces a dist/ folder, and I rsync that folder to my own server (the same box behind panel.itahir.com). No CI, no platform, no build minutes. A static folder is the most durable artifact I know how to make, and it means the site has no moving parts to break at 2am.
The blog is a content collection — and slug is a trap
The blog runs on Astro’s content collections with the glob loader. Each post exists as two Markdown files that share one translation key — src/content/blog/en/<key>.md and src/content/blog/az/<key>.md — and the schema validates the frontmatter with Zod:
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
lang: z.enum(['en', 'az']),
tkey: z.string(),
summary: z.string().optional(),
draft: z.boolean().default(false),
}),
});
Here’s the trap I want to save you from. My first instinct was to call that translation field slug — the English and Azerbaijani versions of one post obviously share a slug, and /writing/<slug>/ is the URL I want in both locales. But the glob loader treats a frontmatter slug as the entry id. The id has to be unique across the collection, so two files declaring the same slug collide — one quietly wins, the other vanishes. I lost a few minutes to a post that simply wasn’t there before I understood why.
The fix was a one-word rename: I call the field tkey (translation key) instead, and let the loader derive ids from the file path (en/foo, az/foo) as it likes. Then getStaticPaths builds the URL from my own field, not the loader’s id:
const posts = await getCollection('blog', ({ data }) =>
data.lang === 'en' && !data.draft);
return posts.map((entry) => ({
params: { slug: entry.data.tkey },
props: { entry },
}));
So tkey is the join key between the two language files and the public slug, while Astro’s id stays an internal detail I never fight. The two locales get parallel [slug].astro routes — one under /writing/, one under /az/writing/ — each filtering the collection by lang. Drafts are filtered out by the same predicate, so an unfinished post is invisible in production without me deleting anything.
Per-locale data, not a translation library
For the non-prose chrome — the hero, the work index, nav labels, the colophon — I didn’t reach for an i18n library. Each language is a plain typed TypeScript module (src/data/en.ts, src/data/az.ts) that both satisfy one SiteContent interface. The English page imports en, the Azerbaijani page imports az, and the same components render either one. The interface is the contract: if I add a hero fact in English and forget it in Azerbaijani, TypeScript tells me before the build finishes. It’s the least clever option and I’d pick it again — no runtime lookup, no missing-key fallback logic, just two objects of the same shape.
Dates are the same spirit. Rather than depend on Node’s ICU locale data being present on whatever machine runs the build, I wrote a tiny formatter with the month names hard-coded for both languages, and it reads the UTC parts of the date so a midnight-UTC frontmatter date never slips to the previous day in a negative timezone. Small, but it’s exactly the kind of thing that’s invisible until it’s wrong.
The language toggle carries your place
The EN/AZ toggle in the masthead is a normal <a> pointing at the counterpart locale’s URL — it works with no JavaScript at all. But on the long home page, if you’re reading the Writing section and switch languages, landing back at the top is annoying. So a tiny progressive-enhancement script copies the current in-page hash onto the toggle’s href, and keeps it in sync on hashchange:
const base = toggle.getAttribute('href')?.split('#')[0] ?? '';
toggle.setAttribute('href', base + window.location.hash);
Switch to Azerbaijani while you’re at #writing, and you arrive at /az/#writing — same section, other language. With JS off you still get the right page, just at the top. That’s the rule I tried to hold everywhere: the baseline works, the script only makes it nicer.
An accordion that respects the platform
The work index uses native <details>/<summary> rows. Native <details> toggles instantly with no animation, so I wrote a small animator on top of the Web Animations API. The trick is that the native open attribute stays the single source of truth: I intercept the summary click, preventDefault the instant toggle, and animate the element’s own height between its collapsed (summary-only) and expanded (summary + panel) sizes, only flipping open at the very start and end. Keyboard, find-in-page, and the no-JS case all keep working because I never replace the native mechanism — I just animate around it.
Two details I care about. First, the duration scales to the distance traveled, clamped, so a tall panel doesn’t crawl and a short one doesn’t snap:
private duration(from: string, to: string): number {
const delta = Math.abs(parseFloat(from) - parseFloat(to));
return Math.min(420, Math.max(180, Math.round(delta * 0.9)));
}
Second, prefers-reduced-motion. If the reader has asked for less motion, I don’t attach the handler at all — the browser’s instant native toggle is left completely untouched, which is better than animating a zero-duration fake. The CSS honours the same preference for the panel’s fade-in. Accessibility here isn’t a checkbox; it’s leaning on what the browser already does well and getting out of its way.
What I left out
There’s no analytics, no cookie banner, no comment system, no JS framework hydrating anything. The only client-side code is the hash-carrying toggle, the accordion, and a blinking dot on the logo (also reduced-motion-guarded). One of the quiet pleasures of a static editorial site is how much you get to not build. The hard parts ended up being the boring-sounding ones — a field named slug, a date that drifts a day, a toggle that forgets where you were — and those are exactly the parts worth writing down.