jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Astro Deep Dive — Islands, Content Collections & Zero-JS by Default (2026)

A bilingual deep dive into Astro 5: the Islands architecture, .astro component anatomy, client directives, SSG vs SSR, Content Collections with Zod, routing, integrations, and View Transitions — using this blog's real config as examples.

Why Astro Exists {Tại sao có Astro}

Most frameworks ship a JavaScript runtime to the browser and then render your page {Hầu hết framework gửi một runtime JavaScript xuống trình duyệt rồi mới render trang}. For a dashboard that’s fine {Với dashboard thì ổn}. For a blog, a marketing site, or docs — pages that are mostly content — you’re shipping a framework just to render static text {Với blog, trang marketing, hay docs — những trang chủ yếu là nội dung — bạn đang gửi cả một framework chỉ để render chữ tĩnh}.

Astro flips the default {Astro lật ngược mặc định}: render to HTML at build time, ship zero JavaScript unless a component explicitly asks for it {render ra HTML lúc build, gửi zero JavaScript trừ khi một component yêu cầu cụ thể}. The result is pages that are fast because there’s almost nothing to download or execute {Kết quả là trang nhanh vì gần như không có gì để tải hay chạy}.

This very blog runs on Astro 5 {Chính blog này chạy trên Astro 5}, so every example below is real code from its config — not a toy {nên mọi ví dụ dưới đây là code thật từ config của nó — không phải đồ chơi}.

Mental model {Mental model}: Astro is an HTML-first framework. JavaScript is opt-in, per component, per loading strategy {Astro là framework HTML-first. JavaScript là tùy chọn, theo từng component, theo từng chiến lược load}.


The Islands Architecture {Kiến trúc Islands}

An island is an interactive UI component embedded in an otherwise static HTML page {Island là một component UI tương tác nhúng vào một trang HTML vốn tĩnh}. The “sea” of static HTML is rendered once at build time; only the islands ship JavaScript and hydrate in the browser {“Biển” HTML tĩnh được render một lần lúc build; chỉ các island gửi JavaScript và hydrate trên trình duyệt}.

┌─────────────────────────────────────────────┐
│  Static HTML (0 KB JS) — header, prose, footer│
│                                               │
│   ┌──────────────┐         ┌───────────────┐ │
│   │ <Search />   │         │ <CartButton />│ │  ← islands
│   │ client:idle  │         │ client:visible│ │    (hydrate
│   └──────────────┘         └───────────────┘ │     independently)
│                                               │
└─────────────────────────────────────────────┘

Compare this to the two older models {So sánh với hai mô hình cũ hơn}:

Model {Mô hình}What ships {Gửi gì}Hydration
SPA (React/Vue app)Whole app as JS {Cả app dạng JS}Everything, upfront {Tất cả, ngay từ đầu}
SSR + full hydration (Next pages)HTML + JS for the whole tree {HTML + JS cho cả cây}Whole page re-hydrates {Cả trang re-hydrate}
Islands (Astro)HTML + JS only for islands {HTML + JS chỉ cho island}Per-island, independent {Theo từng island, độc lập}

The key win {Lợi ích then chốt}: a heavy page with one tiny interactive widget ships the JS for that widget only, not for the entire page {một trang nặng nhưng chỉ có một widget tương tác nhỏ sẽ gửi JS chỉ cho widget đó, không phải cả trang}.


Anatomy of a .astro Component {Giải phẫu component .astro}

An .astro file has two parts split by a --- fence (the “code fence”) {File .astro có hai phần ngăn bởi hàng rào --- (gọi là “code fence”)}:

---
// 1) Component script — runs at BUILD time (or request time on SSR).
//    Plain TypeScript. Fetch data, import components, define props.
interface Props {
  title: string;
  tags: string[];
}
const { title, tags } = Astro.props;
const featured = tags.includes('featured');
---

<!-- 2) Template — HTML + JSX-like expressions -->
<article class:list={['card', { featured }]}>
  <h2>{title}</h2>
  <ul>
    {tags.map((t) => <li>{t}</li>)}
  </ul>
</article>

<!-- 3) Scoped <style> — automatically scoped to THIS component -->
<style>
  .card { border: 1px solid var(--border); }
  .featured { border-color: var(--accent); }
</style>

Three things to internalize {Ba điều cần thấm}:

  1. The script runs server-side / at build, never in the browser {Script chạy ở phía server / lúc build, không bao giờ ở trình duyệt}. There’s no client runtime for it — it’s just used to produce HTML {Không có runtime client cho nó — nó chỉ dùng để tạo ra HTML}.
  2. class:list is an Astro directive that conditionally joins classes (no clsx needed) {class:list là directive của Astro để nối class có điều kiện (không cần clsx)}.
  3. <style> is scoped by default {<style> mặc định được scope}. Astro adds a hashed attribute so styles don’t leak — no CSS modules, no naming conventions {Astro thêm một attribute băm để style không rò rỉ — không cần CSS modules, không cần quy ước đặt tên}.

Client Directives — Opting Into JavaScript {Client Directive — Bật JavaScript}

A plain .astro component renders to HTML and ships no JS {Một component .astro thường render ra HTML và gửi không JS}. To make a framework component (React, Vue, Svelte, Solid) interactive, you add a client:* directive that decides when it hydrates {Để một component framework (React, Vue, Svelte, Solid) có tương tác, bạn thêm directive client:* quyết định khi nào nó hydrate}.

---
import Search from '../components/Search.tsx';
import Comments from '../components/Comments.tsx';
import Chart from '../components/Chart.tsx';
---

<Search client:load />     <!-- hydrate immediately on page load -->
<Chart client:visible />   <!-- hydrate when scrolled into view -->
<Comments client:idle />   <!-- hydrate when the main thread is idle -->
DirectiveHydrates when… {Hydrate khi…}Use for {Dùng cho}
client:loadPage loads {Trang tải xong}Above-the-fold, critical interactivity {Tương tác quan trọng, trên màn đầu}
client:idleBrowser is idle {Trình duyệt rảnh}Low-priority widgets {Widget ưu tiên thấp}
client:visibleElement enters viewport {Phần tử vào viewport}Below-the-fold, charts, carousels {Dưới màn đầu, chart, carousel}
client:media={query}A media query matches {Media query khớp}Mobile-only / desktop-only UI {UI chỉ mobile / chỉ desktop}
client:only={framework}Client only — skip SSR {Chỉ client — bỏ SSR}Components that can’t render server-side {Component không render được phía server}

The directive is the dial for performance {Directive là núm xoay cho hiệu năng}: nothing below the fold needs to block initial load {không gì dưới màn đầu cần chặn lần tải đầu tiên}.

Note for this blog {Lưu ý cho blog này}: it ships no framework islands at all {nó không có island framework nào} — interactivity (scroll-reveal, theme) is plain vanilla JS in <script> tags, which Astro bundles and optimizes automatically {tương tác (scroll-reveal, theme) là vanilla JS trong thẻ <script>, được Astro tự bundle và tối ưu}.


Rendering Modes — SSG, SSR, Hybrid {Chế độ render — SSG, SSR, Hybrid}

Astro renders pages in one of two ways, controlled per-project and per-page {Astro render trang theo một trong hai cách, điều khiển theo project và theo từng trang}:

  • SSG (static) — pages built to HTML at deploy time. Default. Best for content {SSG (tĩnh) — trang build ra HTML lúc deploy. Mặc định. Tốt nhất cho nội dung}.
  • SSR (on-demand) — pages rendered per request on a server. Needs an adapter {SSR (theo yêu cầu) — trang render mỗi request trên server. Cần một adapter}.
// SSR requires an adapter for your host:
import { defineConfig } from 'astro/config';
import node from '@astrojs/node'; // or @astrojs/vercel, @astrojs/cloudflare

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
});

In Astro 5 there is no global output: 'hybrid' — every page is static by default, and you opt individual pages into on-demand rendering with export const prerender = false {Ở Astro 5 không có output: 'hybrid' toàn cục — mọi trang mặc định tĩnh, và bạn cho từng trang sang render theo yêu cầu bằng export const prerender = false}:

---
// This single page is rendered on the server per request;
// every other page stays static.
export const prerender = false;
const data = await fetch('https://api.example.com/live').then((r) => r.json());
---
<p>Live value: {data.value}</p>

This blog is pure SSG — no adapter, no server. It builds to a folder of HTML and deploys to GitHub Pages / Cloudflare Pages as static files {Blog này là SSG thuần — không adapter, không server. Nó build ra một thư mục HTML và deploy lên GitHub Pages / Cloudflare Pages dạng file tĩnh}.


Content Collections + Zod {Content Collections + Zod}

This is Astro’s killer feature for content sites {Đây là tính năng sát thủ của Astro cho trang nội dung}: a typed, validated layer over your Markdown/MDX files {một lớp có type, được kiểm tra trên các file Markdown/MDX của bạn}. You define a schema with Zod, and Astro validates every file’s frontmatter at build time {Bạn định nghĩa schema bằng Zod, và Astro kiểm tra frontmatter của mọi file lúc build}.

Here is this blog’s actual posts collection {Đây là collection posts thật của blog này}:

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

const posts = defineCollection({
  // Astro 5 glob loader — content location is decoupled from routing.
  loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/posts' }),
  schema: ({ image }) =>
    z.object({
      title: z.string().min(1).max(120),
      description: z.string().min(1).max(240),
      pubDate: z.coerce.date(),
      updatedDate: z.coerce.date().optional(),
      tags: z.array(z.string()).default([]),
      draft: z.boolean().default(false),
      cover: image().optional(), // image() lets Astro optimize covers
      coverAlt: z.string().optional(),
    }),
});

export const collections = { posts };

Why this matters {Tại sao điều này quan trọng}:

  • Build fails on bad data {Build fail khi data sai}: forget a description, or write one over 240 chars, and the build errors with the exact file and field — content bugs caught before deploy {quên description, hay viết quá 240 ký tự, build sẽ báo lỗi đúng file và field — bug nội dung bị bắt trước khi deploy}.
  • Full type-safety in queries {Type-safe đầy đủ khi query}: entry.data.title is typed as string, tags as string[] {entry.data.title có type string, tagsstring[]}.

Querying the collection is fully typed {Query collection cũng có type đầy đủ}:

import { getCollection } from 'astro:content';

// `draft` is typed boolean; the filter is checked at compile time.
const published = await getCollection('posts', ({ data }) => !data.draft);
const sorted = published.sort(
  (a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()
);

The glob loader (astro/loaders) is the Astro 5 way {glob loader (astro/loaders) là cách của Astro 5}: content location is decoupled from URL routing, so you can later move posts to a separate repo without touching routes {vị trí nội dung tách khỏi routing URL, nên sau này bạn có thể chuyển posts sang repo riêng mà không đụng route}.


File-Based Routing + getStaticPaths {Routing theo file + getStaticPaths}

A file in src/pages/ becomes a route {Một file trong src/pages/ trở thành một route}:

src/pages/index.astro          →  /
src/pages/about.astro          →  /about
src/pages/blog/[slug].astro    →  /blog/:slug   (dynamic)
src/pages/tags/[tag].astro     →  /tags/:tag    (dynamic)

For static dynamic routes, you tell Astro every path to build via getStaticPaths {Với route động tĩnh, bạn báo cho Astro mọi path cần build qua getStaticPaths}:

---
// src/pages/blog/[slug].astro
import { getCollection, render } from 'astro:content';

export async function getStaticPaths() {
  const posts = await getCollection('posts', ({ data }) => !data.draft);
  return posts.map((post) => ({
    params: { slug: post.id },  // → /blog/<id>/
    props: { post },            // passed to the page below
  }));
}

const { post } = Astro.props;
const { Content } = await render(post); // compiled MDX → component
---

<h1>{post.data.title}</h1>
<Content />

getStaticPaths runs at build time and returns the full list of pages to generate {getStaticPaths chạy lúc build và trả về toàn bộ danh sách trang cần sinh}. Each params becomes a URL, each props is handed to that page {Mỗi params thành một URL, mỗi props được trao cho trang đó}. This is how this blog turns ~60 MDX files into ~60 static HTML pages {Đây là cách blog này biến ~60 file MDX thành ~60 trang HTML tĩnh}.


Integrations & Config Tour {Dạo qua Integrations & Config}

Astro is configured in astro.config.mjs {Astro được cấu hình trong astro.config.mjs}. Here are the meaningful parts of this blog’s config, annotated {Đây là các phần quan trọng trong config của blog này, có chú thích}:

import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  site: 'https://jvinhit.github.io',
  // 'always' keeps trailing slashes consistent for sitemap + canonical URLs.
  trailingSlash: 'always',

  integrations: [
    mdx(),       // .mdx support: Markdown + components + expressions
    sitemap({    // auto-generate sitemap-index.xml + sitemap-0.xml
      filter: (page) => !page.includes('/drafts/'),
    }),
  ],

  vite: {
    // Tailwind CSS 4 is a Vite plugin now — no PostCSS config needed.
    plugins: [tailwindcss()],
  },

  markdown: {
    // Shiki = build-time syntax highlighting (zero client JS).
    shikiConfig: {
      themes: { light: 'github-dark-default', dark: 'github-dark-default' },
      wrap: true,
    },
  },

  build: { format: 'directory' }, // /blog/slug/index.html (clean URLs)

  image: {
    // Build-time image optimization with sharp.
    service: { entrypoint: 'astro/assets/services/sharp' },
  },
});

Things worth calling out {Những điểm đáng nói}:

  • Integrations are composable hooks {Integration là các hook ghép được}. They tap lifecycle events like astro:build:done. This blog even has a tiny custom integration that copies sitemap-0.xmlsitemap.xml for crawlers that expect the conventional filename {Chúng móc vào sự kiện vòng đời như astro:build:done. Blog này còn có một integration tự viết nhỏ copy sitemap-0.xmlsitemap.xml cho crawler mong đợi tên file quy ước}.
  • Tailwind 4 is just a Vite plugin {Tailwind 4 chỉ là một Vite plugin} — @tailwindcss/vite — no tailwind.config.js, no PostCSS chain {không cần tailwind.config.js, không cần chuỗi PostCSS}.
  • Shiki highlights code at build time {Shiki highlight code lúc build}, producing colored HTML with zero client-side highlighting JS {tạo ra HTML có màu với zero JS highlight phía client}. (See Frontend Build Tools & Package Managers for the tooling layer underneath.)

View Transitions — Native Page Animations {View Transitions — Animation chuyển trang gốc}

A multi-page site can feel like an SPA with Astro’s <ClientRouter /> {Một trang multi-page có thể mượt như SPA nhờ <ClientRouter /> của Astro}. Drop it in your <head> and Astro intercepts navigations, swapping the DOM and animating with the browser’s View Transitions API {Đặt nó vào <head> và Astro chặn việc điều hướng, đổi DOM và animate bằng View Transitions API của trình duyệt}:

---
// src/layouts/BaseLayout.astro
import { ClientRouter } from 'astro:transitions';
---
<html lang="en">
  <head>
    <ClientRouter />
  </head>
  <body><slot /></body>
</html>

You get smooth cross-page transitions and persisted state, while keeping the simplicity (and SEO) of separate HTML pages {Bạn có chuyển trang mượt và giữ được state, mà vẫn giữ sự đơn giản (và SEO) của các trang HTML riêng biệt}. For the underlying browser API, see View Transitions API — Native Page Animations {Về API trình duyệt bên dưới, xem bài View Transitions API}.


Performance & Deployment {Hiệu năng & Triển khai}

Why Astro sites score well on Core Web Vitals {Vì sao site Astro đạt điểm Core Web Vitals tốt}:

  1. Zero-JS baseline {Nền tảng zero-JS}: a content page ships only HTML + CSS. Nothing to parse, nothing to execute {trang nội dung chỉ gửi HTML + CSS. Không gì để parse, không gì để chạy}.
  2. Per-island hydration {Hydrate theo island}: interactive bits load independently, never blocking the page {phần tương tác load độc lập, không chặn trang}.
  3. Build-time work {Việc làm lúc build}: Markdown rendering, syntax highlighting, image optimization all happen once at build, not per visitor {render Markdown, highlight cú pháp, tối ưu ảnh đều làm một lần lúc build, không phải mỗi khách}.

Deploying static Astro is trivial {Deploy Astro tĩnh rất đơn giản}: astro build emits a dist/ folder of HTML/CSS/JS that any static host serves {astro build xuất ra thư mục dist/ chứa HTML/CSS/JS mà bất kỳ static host nào cũng phục vụ được}:

astro build      # → dist/ (static files)
astro preview     # serve dist/ locally to verify

This blog deploys to GitHub Pages via Actions {Blog này deploy lên GitHub Pages qua Actions} — see GitHub Pages & Actions Deep Dive for that pipeline {xem bài GitHub Pages & Actions để biết pipeline đó}.


When to Use Astro — and When Not {Khi nào dùng Astro — và khi nào không}

Reach for Astro when {Chọn Astro khi}:

  • Content-heavy sites: blogs, docs, marketing, portfolios {Trang nhiều nội dung: blog, docs, marketing, portfolio}.
  • You want minimal JS and great defaults for SEO + performance {Bạn muốn ít JS và mặc định tốt cho SEO + hiệu năng}.
  • You want to mix frameworks (a React widget here, a Svelte one there) on one page {Bạn muốn trộn framework (chỗ này React, chỗ kia Svelte) trên cùng một trang}.

Look elsewhere when {Cân nhắc lựa chọn khác khi}:

  • The app is highly interactive everywhere — a dashboard, editor, or real-time app — where almost every component needs client state {App tương tác mạnh khắp nơi — dashboard, editor, app real-time — nơi gần như mọi component cần state phía client}. A full SPA framework (Next, Remix, SvelteKit) fits better {Một framework SPA đầy đủ (Next, Remix, SvelteKit) hợp hơn}.
  • You need deep, app-wide client-side routing with shared in-memory state across views {Bạn cần routing phía client toàn app, chia sẻ state trong bộ nhớ giữa các view}.

Quick Reference {Tham chiếu nhanh}

Concept {Khái niệm}Astro answer {Câu trả lời của Astro}
Default JS shipped {JS gửi mặc định}Zero — opt in per island
Make a component interactive {Cho component có tương tác}client:load / idle / visible / media / only
Static vs server {Tĩnh vs server}SSG default; export const prerender = false + adapter for SSR
Typed content {Nội dung có type}Content Collections + Zod schema
Dynamic static pages {Trang tĩnh động}getStaticPaths()
Styling {Tạo kiểu}Scoped <style>, or Tailwind via @tailwindcss/vite
Page transitions {Chuyển trang}<ClientRouter /> + View Transitions API
Deploy {Triển khai}astro build → static host (or adapter for SSR)

Astro’s bet is simple {Cược của Astro rất đơn giản}: most of the web is content, and content shouldn’t pay the tax of a client-side framework {phần lớn web là nội dung, và nội dung không nên trả thuế cho một framework phía client}. When that bet matches your project, few tools feel as fast — to build and to load {Khi cược đó khớp với dự án của bạn, ít công cụ nào nhanh bằng — cả khi build lẫn khi tải}.

Related {Liên quan}: Frontend Build Tools & Package Managers · View Transitions API · GitHub Pages & Actions Deep Dive.