perkun.eu Services Portfolio Blog About Contact PL
← Blog

7/30/2024

Astro 4 — why we rewrote our site from static HTML

TL;DR: Astro generates static HTML with zero JavaScript by default. Bilingual support, Markdown blog, GSAP animations, Three.js — everything works. Deploy is nginx serving the dist/ folder. Lighthouse score: 98/100 Performance.

For a year our site was hand-written HTML with a copy-pasted header and footer in every file. Changing a single navigation link required editing 12 files. Adding a new blog post meant creating an HTML file from scratch with the full page structure. That doesn’t scale.

The problem with static HTML

Manual HTML is tempting: zero dependencies, zero build system, open your editor and write. The problem starts when you have more than 5 pages. You copy the header, footer, and <head> meta tags into every file. Change the template’s <title>? Edit every file individually. Miss one and you have inconsistencies.

The blog was even harder. Each article was a separate HTML file with hard-coded dates, tags, and the full page structure. The article list on the homepage — a manually updated array of links. Sorting by date? A manual ordering task.

Astro solves these problems while preserving the key advantage of static HTML: the output files are pure HTML + CSS with no JavaScript runtime.

Why Astro and not Next.js

Next.js is a great framework, but by default it ships the React runtime to the browser — around 100–150kb of minified JavaScript before your page can render. For an application with heavy interactivity (dashboards, forms, real-time data) that cost is justified. For a presentation site with a blog — you’re overpaying.

Astro takes the opposite approach: zero JavaScript by default. You add JS only to the components that actually need it. Our site has GSAP animations (intro), Three.js (3D background in the hero section), and one contact form. Only those three elements have JavaScript attached. The rest of the site — zero JS, pure HTML.

JS bundle size: ~85kb for Three.js + GSAP + form. A comparable site in Next.js would have 100kb+ React runtime before loading our scripts.

i18n in Astro

Astro 4 has built-in multilingual routing support. Configuration in astro.config.mjs:

import { defineConfig } from 'astro/config';

export default defineConfig({
  i18n: {
    defaultLocale: 'pl',
    locales: ['pl', 'en'],
    routing: {
      prefixDefaultLocale: false,
    },
  },
});

Translation files as TypeScript const (not JSON — typing helps):

// src/i18n/pl.ts
export const pl = {
  nav: {
    home: 'Strona główna',
    blog: 'Blog',
    contact: 'Kontakt',
  },
  hero: {
    headline: 'Budujemy rzeczy które działają',
    subheadline: 'Laravel, Docker, IoT, Web3',
  },
} as const;

In Astro components:

---
import { useTranslations } from '../i18n/utils';
const t = useTranslations(Astro.currentLocale ?? 'pl');
---
<nav>
  <a href="/">{t('nav.home')}</a>
  <a href="/blog">{t('nav.blog')}</a>
</nav>

For SEO, Astro automatically generates hreflang tags if you have the appropriate components in <head>. Blog posts have a pl- or en- prefix in the filename — routing for /pl/blog/ and /en/blog/ is generated automatically.

Content Collections for the blog

All articles live in src/content/blog/ as Markdown files with frontmatter. Astro validates frontmatter through a Zod schema defined in src/content/config.ts:

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.date(),
    lang: z.enum(['pl', 'en']),
    tags: z.array(z.string()),
  }),
});

export const collections = { blog };

If you forget the title field in a frontmatter — build error with the exact file and field name. Zero silent failures.

In the page component listing articles:

---
import { getCollection } from 'astro:content';

const posts = (await getCollection('blog'))
  .filter(p => p.data.lang === 'pl')
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
---

Data is fully typed — post.data.pubDate is a Date, not a string. No parsing or casting needed.

Deploy

The deploy process is as simple as it gets:

#!/bin/bash
npm run build                                          # generates dist/
tar -czf dist.tar.gz dist/                             # archive
sshpass -p "$QNAP_PASS" scp -P 222 dist.tar.gz \
  admin@192.168.10.200:/tmp/                           # copy to QNAP
sshpass -p "$QNAP_PASS" ssh -p 222 admin@192.168.10.200 \
  "docker cp /tmp/dist.tar.gz nginx:/usr/share/nginx/ && \
   docker exec nginx tar -xzf /usr/share/nginx/dist.tar.gz \
     -C /usr/share/nginx/html --strip-components=1"

An nginx:alpine container serves the static files from dist/. Zero PHP, zero Node.js in production. Caching is trivial — Cache-Control: max-age=31536000 for files with a hash in the name, no-cache for HTML.

Summary

Astro 4 is the right framework for sites that don’t need application-level interactivity. Components provide reusability (no more copy-pasted headers), Content Collections eliminate manual blog management, built-in i18n handles bilingual content, and the output is clean HTML with minimal JavaScript — exactly as much as needed.