Quay lại Insights
Phần mềm 14 min read

Từ Bỏ Next.js Sang Astro: Hướng Dẫn Migration Toàn Tập & Tối Ưu Lighthouse 100/100

Case study dịch chuyển nicknguyen8.com từ Next.js 14 sang Astro 5: Islands Architecture, Nano Stores và cách cắt giảm 95% JavaScript payload.

Tổng Quan

Next.js từ lâu là lựa chọn phổ biến cho các dự án Web, từ Web App B2B phức tạp (SaaS) đến các trang Portfolio cá nhân. Tuy nhiên, kiến trúc của Next.js App RouterReact Server Components (RSC) có những hạn chế đo lường được đối với các trang thiên về nội dung tĩnh (Content-Driven Sites), chủ yếu do dung lượng JavaScript gửi xuống trình duyệt trên mỗi trang.

Bài viết này đi sâu phân tích và so sánh kỹ thuật giữa Next.js và Astro – một framework đang được đánh giá rất cao trong mảng phát triển website nội dung nhờ ưu thế tối ưu hóa chỉ số Lighthouse, giảm thiểu độ trễ TTFB và tăng cường trải nghiệm người dùng.

Case study này ghi nhận quá trình tái cấu trúc (Refactoring) toàn bộ hệ thống nicknguyen8.com từ Next.js 14 sang Astro 5. Quá trình bao gồm việc chuẩn hoá lại kiến trúc, routing, state management và CI/CD pipeline.

Công nghệ sử dụng:

  • Astro 5.x
  • Vanilla CSS (Custom properties, không dùng utility framework)
  • React 18 (Chỉ dùng cho Interactive Islands)
  • Nano Stores (Thay thế React Context)
  • Cloudflare Pages (Hosting & CDN)
  • Keystatic (Local Git CMS)

Phần 1: Phân Tích Hạn Chế Của Next.js Trong Việc Xây Dựng Trang Nội Dung

1.1 Vấn đề Hydration Waterfall

Dù sử dụng Next.js Pages Router hay App Router, đối với một trang nền tảng văn bản và hình ảnh như Blog, framework này mặc định vẫn thực thi quy trình Hydration phía Client.

Hydration là quá trình trình duyệt tải xuống bundle JavaScript (thường chứa React Runtime), sau đó chạy quá trình tái đồng bộ và gắn các event listeners vào cây DOM HTML đã được Server (hoặc bước SSG) render trước đó.

Biểu đồ dưới đây mô tả luồng tải trang truyền thống của React/Next.js so với luồng tải tĩnh:

sequenceDiagram
    participant Browser
    participant Server (Next.js)
    
    Browser->>Server (Next.js): GET /blog/article
    Server (Next.js)-->>Browser: Trả về HTML (Header, Nội dung, Footer)
    Note over Browser: User thấy UI nhưng chưa tương tác được
    Browser->>Server (Next.js): GET frontend-bundle.js (React Runtime)
    Server (Next.js)-->>Browser: Trả về lượng lớn file JS
    Note over Browser: Trùng lập xử lý (TBT Spike)
    Browser->>Browser: Parse JS & Thực thi Hydration
    Note over Browser: Trang web Interactive hoàn toàn

Hậu quả của quy trình này trên một trang blog:

  1. CPU Spike: Các thiết bị di động có cấu hình thấp phải tiêu tốn tài nguyên CPU để phân tích và thực thi hàng chục đến hàng trăm KB JavaScript, dù người dùng chỉ có nhu cầu đọc văn bản.
  2. Total Blocking Time (TBT) cao: Main thread của trình duyệt bận xử lý tác vụ Hydration, khiến giao diện có thể tạm thời không phản hồi các thao tác click của người dùng.
  3. Băng thông phân phối: Gửi kèm các file JS .rsc và payload JSON làm tăng kích thước tải xuống thư mục tĩnh.

1.2 Giới Hạn Của RSC (React Server Components)

Next.js App Router cung cấp RSC nhằm giảm lượng JavaScript thiết lập phía Client. Tuy nhiên, hệ thống vẫn phải bắt buộc tải thư viện React Runtime và Client Router để phục vụ việc chuyển trang (Client-side Navigation) theo dòng SPA.

Đổi lại với Astro, trên các page văn bản, lượng JavaScript phân luồng tải xuống mặc định là 0 KB.


Phần 2: Astro Islands – Kiến Trúc Các UI Component Độc Lập

Khác với Next.js, triết lý xây dựng của Astro đặt trọng tâm vào khối “Zero JavaScript by Default”.

Astro Compiler sẽ render toàn thể phần React/Vue/Svelte layer sang cấu hình HTML tĩnh ở thời điểm Build. Khi gặp Component cần đảm nhận các User Interaction (tương tác người dùng), những module UI cụ thể này sẽ được khai báo rõ ràng để cô lập phạm vi tải JS – Astro định nghĩa cấu hình này là kiến trúc đảo (Islands Architecture).

Cấu trúc luồng tải được đối chiếu qua sơ đồ bên dưới:

graph TD
    subgraph n1 ["Next.js SPA/RSC (Load Bundle JS)"]
        N_Root[Root Component]
        N_H[Header Nav]
        N_M[Main Content]
        N_P[Text Paragraph]
        N_I[Image]
        N_S[Search Bar]
        
        N_Root --> N_H
        N_Root --> N_M
        N_M --> N_P
        N_M --> N_I
        N_H --> N_S
        
        style N_Root fill:#ef4444,stroke:#991b1b,color:#fff
        style N_H fill:#ef4444,stroke:#991b1b,color:#fff
        style N_M fill:#ef4444,stroke:#991b1b,color:#fff
        style N_P fill:#ef4444,stroke:#991b1b,color:#fff
        style N_I fill:#ef4444,stroke:#991b1b,color:#fff
        style N_S fill:#ef4444,stroke:#991b1b,color:#fff
    end

    subgraph n2 ["Astro Islands (Tĩnh tối đa)"]
        A_Root[Astro Layout - Zero JS]
        A_H[Astro Header - Zero JS]
        A_M[Astro Content - Zero JS]
        A_P[Markdown Text - Zero JS]
        A_I[Astro Assets - Zero JS]
        A_S[React Island - search.tsx]
        
        A_Root --> A_H
        A_Root --> A_M
        A_M --> A_P
        A_M --> A_I
        A_H --> A_S
        
        style A_Root fill:#10b981,stroke:#047857,color:#fff
        style A_H fill:#10b981,stroke:#047857,color:#fff
        style A_M fill:#10b981,stroke:#047857,color:#fff
        style A_P fill:#10b981,stroke:#047857,color:#fff
        style A_I fill:#10b981,stroke:#047857,color:#fff
        style A_S fill:#3b82f6,stroke:#1d4ed8,color:#fff
    end

Như sơ đồ mô tả: Trong khi Next.js mặc định render JS cho toàn bộ component tree, Astro chỉ gói gọn quá trình nạp JS vào một module cần thiết (ví dụ Search Bar).

Các cấu hình Hydration Directives trong Astro:

  • client:load: Tải và chạy JS tại thời điểm duyệt trang mở ra (Được dùng phổ biến cho phần Header Navbar hoặc tính năng Theme Mode).
  • client:idle: Tải và gắn kết JS vào những lúc Thread phía Browser rảnh rỗi (Thường tối ưu cho các Tracking tags, Analytics scripts).
  • client:visible: JS chỉ thực thi việc fetch khi vành đai Component lọt vào viewport xuất hiện (Sử dụng cho các chức năng Bình Luận cuối bài viết, Carousel thư viện Ảnh nhằm nâng cực điểm hiệu suất ban đầu).
  • client:media="{max-width: 768px}": Chỉ tải phần module Interactive đối với những quy mô Device chuẩn bị Mobile view (Tối ưu để chặn file Hamburger Header chuyển đến Desktop UI).

Phần 3: Hướng Dẫn Chuyển Đổi Thực Tế (Migration Guide)

Việc di chuyển Framework đòi hỏi xây lại toàn bộ Data Flow và đường truyền Component. Dưới đây là các kỹ thuật thực tế để chuyển luồng code từ Next.js qua định dạng Astro.

3.1 Chuyển đổi Data Fetching: Loại Bỏ getStaticProps

Trong Astro, lập trình viên không phải setup bằng cách export một hàm Fetch async cố định ra ngoài chức năng render như Next.js. Mệnh lệnh Top-level Await có thể được triển khai trực tiếp vào khu vực Astro Component Script (nằm kẹp giữa cặp ---).

Mẫu Code Next.js trước đó (Dòng Pages Router):

export async function getStaticProps() {
  const res = await fetch('https://api.github.com/repos/nicknguyen8/nicknguyen8.com');
  const repo = await res.json();
  return { props: { stars: repo.stargazers_count } };
}

export default function RepoInfo({ stars }) {
  return <div>N8 Repo có {stars} sao</div>;
}

Mẫu Code Astro thay thế:

---
// src/pages/index.astro
// Toàn bộ dòng script nằm trong block `---` hoạt động chỉ ở Server-side Build. 
// Đảm bảo không lộ thông tin logic API của dự án xuống phía Browser.
const res = await fetch('https://api.github.com/repos/nicknguyen8/nicknguyen8.com');
const repo = await res.json();
const stars = repo.stargazers_count;
---

<!-- Cấu trúc DOM thuần ngay dưới dùng JS biến môi trường -->
<div>N8 Repo có {stars} sao</div>

3.2 File-based Dynamic Routing

Next.js dùng [slug].tsx kết hợp getStaticPaths. Astro dùng [slug].astro với getStaticPaths() tương đương. Điểm khác biệt là data content được truyền trực tiếp qua props tại build time – không cần truy vấn thêm từ page component.

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

export async function getStaticPaths() {
  const posts = await getCollection('insights');

  return posts.map(post => ({
    params: { slug: post.id },
    props: { post }, // Data tĩnh được truyền thẳng vào build props
  }));
}

const { post } = Astro.props;
const { Content, headings } = await post.render(); 
---

<BlogLayout title={post.data.title}>
  <TableOfContents headings={headings} />
  
  <article class="prose dark:prose-invert">
    <Content />
  </article>
</BlogLayout>

3.3 Tối Ưu Quản Lý Dữ Liệu Tĩnh Content Collections

Sự khó khăn khi duy trì Site dựa trên Markdown bằng Next.js là mảng quản trị Metadata files MD/MDX. Lập trình viên phụ thuộc các packages cộng đồng kiểu gray-matter, map File System Path và Setup typing TS bằng các quy chuẩn rườm rà.

Astro thế hệ sau hỗ trợ tính năng Content Collections cung cấp Validation dữ liệu chuẩn hóa hệ Type-Safe an toàn thông qua gói phân tích Zod.

File cấu hình ở src/content.config.ts (Astro 5):

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

const insightsCollection = defineCollection({
  type: 'content', 
  schema: ({ image }) => z.object({
    title: z.string().max(100, "SEO Title vượt ngưỡng"),
    description: z.string().min(50).max(160),
    date: z.date(),
    draft: z.boolean().default(false),
    tags: z.array(z.string()).min(1),
    coverImage: image().optional(), 
  }),
});

export const collections = {
  'insights': insightsCollection,
};

Lưu ý: Nếu MDX Document ghi các dữ liệu Frontmatter bị nhầm Context Syntax (ví dụ sai giá trị Schema Date như 2026-03-XX), hệ điều hành Local môi trường Build Astro sẽ gửi trả báo lỗi Terminal Zod Validation và Cancel Command Build tức thời. Tính minh bạch này cô lập hoàn toàn các sai phạm cú pháp trước lúc lên CI/CD Production.


Phần 4: Vấn Đề State Management Trong Môi Trường Tĩnh Lập

Bài toán thiết lập Component Render với Data luân chuyển sẽ nảy sinh một rào cản: Tại sao khối code React Side Navigation có chức năng State Hook để báo đổi Theme Dark Mode, nhưng không thể cập nhật Real-time tới lớp <Header /> của dự án?

Đặc điểm Astro định dạng luồng trang sang các Islands cô lập độc quyền. Bộ Render Client bao quanh bằng vòng Context API Provider Next.js sẽ bị vỡ vụn khi Component tương tác bị gỡ khỏi Virtual DOM lớn.

Giải pháp chuẩn mực nhất dành riêng cho việc Share Data này là nền tảng Nano Stores. Một tập thư viện Framework-Agnostic chiếm tỷ lệ dung lượng siêu nhỏ dưới 1KB.

graph LR
    NS[(Nano Store Instance)]
    
    subgraph n3 ["Astro/React HTML DOM"]
        R_IS[React Module - Theme Toggle]
        H_IS[React Module - Header Navbar]
    end
    
    R_IS -- trigger state toggle() --> NS
    NS -- lắng nghe và updates --> H_IS
    
    style NS fill:#eab308,stroke:#a16207,color:#fff
    style R_IS fill:#61dafb,stroke:#008b8b,color:#222
    style H_IS fill:#61dafb,stroke:#008b8b,color:#222

Vì Store lưu trữ state bên ngoài vòng Render của React, mọi thay đổi được đồng bộ tự động sang các Islands liên quan mà không cần Provider dùng chung:

1. Khai Báo Biến Tồn Tại Bền Vững (Persistent Storage)

// src/store/themeStore.ts
import { persistentAtom } from '@nanostores/persistent';

export const theme = persistentAtom<'light' | 'dark'>('theme', 'dark');

2. Dựng Khối Client Island (Astro)

---
import Toggle from '../components/ThemeToggle.tsx';
---
<aside>
  <!-- Mount React Node phía Browser -->
  <Toggle client:load />
</aside>

3. Update File Tương Tác Cục Bộ

// src/components/ThemeToggle.tsx
import { useStore } from '@nanostores/react';
import { theme } from '../store/themeStore';

export default function ThemeToggle() {
  const $theme = useStore(theme);

  const toggle = () => {
    theme.set($theme === 'light' ? 'dark' : 'light');
    document.documentElement.classList.toggle('dark', $theme === 'light');
  };

  return (
    <button onClick={toggle} className="p-2 bg-gray-200 dark:bg-gray-800 rounded">
      {$theme === 'light' ? '🌙' : '☀️'}
    </button>
  );
}

Nano Stores giữ JS Bundle ở mức tối thiểu và hoạt động ổn định trên mọi framework frontend.


Phần 5: Tối Ưu Hình Ảnh Tại Build Time

Astro xử lý toàn bộ pipeline tối ưu hình ảnh tại build time thông qua astro:assets tích hợp sẵn, loại bỏ nhu cầu dùng dịch vụ on-demand như Cloudinary hay quota Image Optimization của Vercel.

Trong quá trình npm run build:

  1. Đọc các file ảnh gốc (JPEG/PNG kích thước gốc hàng MB).
  2. Nén và chuyển đổi sang AVIF hoặc WebP.
  3. Tạo srcset responsive theo breakpoints cho từng kích thước thiết bị.
  4. Tự động set widthheight, loại bỏ Cumulative Layout Shift (CLS).

Toàn bộ assets được xuất vào /dist dưới dạng file tĩnh – CDN serve trực tiếp, không cần xử lý server-side mỗi request. Dung lượng ảnh thường giảm 70–80% so với file gốc.


Phần 6: View Transitions API

Kiến trúc MPA không đồng nghĩa với việc từ bỏ chuyển trang mượt mà. Astro tích hợp sẵn View Transitions API của trình duyệt, cho phép hiệu ứng fade và slide khi chuyển trang mà không cần Full Reload.

Chỉ cần thêm một import vào root layout là kích hoạt tính năng này trên toàn site:

---
// Import module tại Layout Main bao hệ thống
import { ViewTransitions } from 'astro:transitions';
---

<!DOCTYPE html>
<html lang="vi">
  <head>
    <meta charset="UTF-8" />
    <title>Nick Nguyen</title>
    <ViewTransitions />
  </head>
  <body>
    <!-- Apply thuộc tính UI Animations -->
    <main transition:animate="fade">
      <slot />
    </main>
  </body>
</html>

Khi chuyển trang, trình duyệt chụp snapshot trang hiện tại, fetch HTML tiếp theo, rồi thực hiện DOM diff với hiệu ứng transition. JavaScript tiêu tốn chỉ vài KB cho logic chuyển trang, không hơn.


Phần 7: Benchmark SEO và Performance

Dữ liệu Lighthouse CI thu thập từ domain production nicknguyen8.com:

Các Đo Lường PerformanceMô Hình Kế Thừa Next.js (SSG)Cấu Trúc Khối Astro (Static + CDN)Kết Quả Tối Ưu
JavaScript Payload tải lần đầu~215 KB12 KB (Modules React Island)Đã lược bỏ tối đa các khối JS thừa
First Contentful Paint (FCP)1.2s0.1sTăng cường khả năng vẽ UI nhanh nhất
Largest Contentful Paint (LCP)1.8s (Mất time Fetch Bundle)0.4s (HTML và Images Load Tức Khắc)Cải thiện mạnh chuẩn Web Vitals
Total Blocking Time (TBT)~300ms (Trình duyệt thực thi Hook)0 ms (Main-Thread thông rảnh)Thiết bị vận hành tác vụ On-Click Tức thì
Lighthouse Performance Score85-92 (Ảnh hưởng do Network)100 / 100 / 100 / 100Điểm đạt đỉnh ở 4 mảng Audit

Lợi ích thực tế của điểm 100/100:

  1. Googlebot Crawl Budget: Dung lượng trang nhỏ giúp Googlebot index bài viết mới nhanh hơn đáng kể.
  2. Page Experience Signals: Core Web Vitals cao cải thiện trực tiếp thứ hạng Google cho các trang nội dung.
  3. Chi phí băng thông: Request đến Cloudflare CDN được tối giản. Traffic tăng không làm tăng chi phí hosting theo tỷ lệ tương ứng.

Phần 8: Giới Hạn Của Kiến Trúc Tĩnh Astro Component

Mọi framework đều có ranh giới.

  1. Quản lý Global SPA State: Các ứng dụng cần state liên tục khi chuyển trang (dashboard tài chính, media player) sẽ gặp rào cản với mô hình MPA isolated của Astro. React Context và Redux hoạt động tốt hơn trong kiến trúc SPA đúng nghĩa như Next.js hay React Router.
  2. Learning Curve với file .astro: Format .astro tách biệt server-side script scope khỏi client-side DOM. Kỹ sư từ nền JSX thuần cần thời gian để quen với cách variable scope, event handler, và component lifecycle khác với React chuẩn.

Lời Kết

Migration từ Next.js sang Astro phản ánh xu hướng kiến trúc chung: delivery tinh gọn hơn, HTML tĩnh theo mặc định, và JavaScript chỉ xuất hiện khi thực sự cần.

Nguyên tắc lựa chọn công nghệ:

  • Admin Dashboard tương tác cao, real-time data feed, hoặc phát media liên tục khi chuyển trang → SPA frameworks (Next.js, React Router/Remix) là lựa chọn đúng.
  • Blog kỹ thuật, portfolio, documentation, và các trang nội dung cần Lighthouse cao và LCP tốt → Astro hiện là framework phù hợp nhất cho use case này.

Ràng buộc của Astro chính là điểm mạnh của nó: buộc phải quyết định tường minh cái gì thực sự cần JavaScript, thay vì hydrate mọi thứ theo mặc định.