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.
Tại sao có Astro
Hầu hết framework gửi một runtime JavaScript xuống trình duyệt rồi mới render trang. Với dashboard thì ổn. 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 lật ngược mặc định: render ra HTML lúc build, gửi zero JavaScript trừ khi một component yêu cầu cụ thể. Kết quả là trang nhanh vì gần như không có gì để tải hay chạy.
Chính blog này chạy trên Astro 5, 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: Astro là framework HTML-first. JavaScript là tùy chọn, theo từng component, theo từng chiến lược load.
Kiến trúc Islands
Island là một component UI tương tác nhúng vào một trang HTML vốn tĩnh. “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)
│ │
└─────────────────────────────────────────────┘
So sánh với hai mô hình cũ hơn:
| Mô hình | Gửi gì | Hydration |
|---|---|---|
| SPA (React/Vue app) | Cả app dạng JS | Tất cả, ngay từ đầu |
| SSR + full hydration (Next pages) | HTML + JS cho cả cây | Cả trang re-hydrate |
| Islands (Astro) | HTML + JS chỉ cho island | Theo từng island, độc lập |
Lợi ích then chốt: 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.
Giải phẫu component .astro
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>
Ba điều cần thấm:
- Script chạy ở phía server / lúc build, không bao giờ ở trình duyệt. Không có runtime client cho nó — nó chỉ dùng để tạo ra HTML.
class:listlà directive của Astro để nối class có điều kiện (không cầnclsx).<style>mặc định được scope. 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 Directive — Bật JavaScript
Một component .astro thường render ra HTML và gửi không JS. Để 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 | Hydrate khi… | Dùng cho |
|---|---|---|
client:load | Trang tải xong | Tương tác quan trọng, trên màn đầu |
client:idle | Trình duyệt rảnh | Widget ưu tiên thấp |
client:visible | Phần tử vào viewport | Dưới màn đầu, chart, carousel |
client:media={query} | Media query khớp | UI chỉ mobile / chỉ desktop |
client:only={framework} | Chỉ client — bỏ SSR | Component không render được phía server |
Directive là núm xoay cho hiệu năng: không gì dưới màn đầu cần chặn lần tải đầu tiên.
Lưu ý cho blog này: nó không có island framework nào — tương tác (scroll-reveal, theme) là vanilla JS trong thẻ
<script>, được Astro tự bundle và tối ưu.
Chế độ render — SSG, SSR, Hybrid
Astro render trang theo một trong hai cách, điều khiển theo project và theo từng trang:
- SSG (tĩnh) — trang build ra HTML lúc deploy. Mặc định. Tốt nhất cho nội dung.
- 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' }),
});
Ở 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>
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
Đây là tính năng sát thủ của Astro cho trang nội dung: một lớp có type, được kiểm tra trên các file Markdown/MDX của bạn. Bạn định nghĩa schema bằng Zod, và Astro kiểm tra frontmatter của mọi file lúc build.
Đâ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 };
Tại sao điều này quan trọng:
- Build fail khi data sai: 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. - Type-safe đầy đủ khi query:
entry.data.titlecó typestring,tagslàstring[].
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()
);
glob loader (astro/loaders) là cách của Astro 5: 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.
Routing theo file + getStaticPaths
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)
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 chạy lúc build và trả về toàn bộ danh sách trang cần sinh. Mỗi params thành một URL, mỗi props được trao cho trang đó. Đây là cách blog này biến ~60 file MDX thành ~60 trang HTML tĩnh.
Dạo qua Integrations & Config
Astro được cấu hình trong astro.config.mjs. Đâ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' },
},
});
Những điểm đáng nói:
- Integration là các hook ghép được. 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 chỉ là một Vite plugin —
@tailwindcss/vite— không cầntailwind.config.js, không cần chuỗi PostCSS. - Shiki highlight code lúc build, tạo ra HTML có màu với zero JS highlight phía client.
View Transitions — Animation chuyển trang gốc
Một trang multi-page có thể mượt như SPA nhờ <ClientRouter /> của Astro. Đặ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>
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. Về API trình duyệt bên dưới, xem bài View Transitions API.
Hiệu năng & Triển khai
Vì sao site Astro đạt điểm Core Web Vitals tốt:
- Nền tảng zero-JS: trang nội dung chỉ gửi HTML + CSS. Không gì để parse, không gì để chạy.
- Hydrate theo island: phần tương tác load độc lập, không chặn trang.
- Việc làm lúc build: 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.
Deploy Astro tĩnh rất đơn giản: 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
Blog này deploy lên GitHub Pages qua Actions — xem bài GitHub Pages & Actions để biết pipeline đó.
Khi nào dùng Astro — và khi nào không
Chọn Astro khi:
- Trang nhiều nội dung: blog, docs, marketing, portfolio.
- Bạn muốn ít JS và mặc định tốt cho SEO + hiệu năng.
- Bạn muốn trộn framework (chỗ này React, chỗ kia Svelte) trên cùng một trang.
Cân nhắc lựa chọn khác khi:
- 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. Một framework SPA đầy đủ (Next, Remix, SvelteKit) hợp hơn.
- Bạn cần routing phía client toàn app, chia sẻ state trong bộ nhớ giữa các view.
Tham chiếu nhanh
| Khái niệm | Câu trả lời của Astro |
|---|---|
| JS gửi mặc định | Zero — opt in per island |
| Cho component có tương tác | client:load / idle / visible / media / only |
| Tĩnh vs server | SSG default; export const prerender = false + adapter for SSR |
| Nội dung có type | Content Collections + Zod schema |
| Trang tĩnh động | getStaticPaths() |
| Tạo kiểu | Scoped <style>, or Tailwind via @tailwindcss/vite |
| Chuyển trang | <ClientRouter /> + View Transitions API |
| Triển khai | astro build → static host (or adapter for SSR) |
Cược của Astro rất đơn giản: 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. 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.
Liên quan: