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}:
- 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}.
class:listis an Astro directive that conditionally joins classes (noclsxneeded) {class:listlà directive của Astro để nối class có điều kiện (không cầnclsx)}.<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 -->
| Directive | Hydrates when… {Hydrate khi…} | Use for {Dùng cho} |
|---|---|---|
client:load | Page loads {Trang tải xong} | Above-the-fold, critical interactivity {Tương tác quan trọng, trên màn đầu} |
client:idle | Browser is idle {Trình duyệt rảnh} | Low-priority widgets {Widget ưu tiên thấp} |
client:visible | Element 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êndescription, 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.titleis typed asstring,tagsasstring[]{entry.data.titlecó typestring,tagslàstring[]}.
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 copiessitemap-0.xml→sitemap.xmlfor 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ỏ copysitemap-0.xml→sitemap.xmlcho 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— notailwind.config.js, no PostCSS chain {không cầntailwind.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}:
- 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}.
- 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}.
- 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.