Diagram nhiều HTML entry points trỏ vào cùng SPA bundle
Web Performance Deep Dive

Multi-Entry HTML cho SPA: Preload đúng resource cho từng route

SPA truyền thống có một vấn đề cố hữu với performance: chỉ có duy nhất một file index.html. Mọi route — homepage, product page, checkout — đều dùng chung một HTML shell. Nghĩa là bạn không thể preload hero image cho homepage mà không preload nó trên checkout page, nơi nó không cần.

Bài này giới thiệu một pattern tôi gọi là Multi-Entry HTML — tạo nhiều HTML files cho các route chính, mỗi file có preload hints riêng, nhưng vẫn load cùng một SPA bundle.

Vấn đề với single index.html

Một SPA booking app điển hình:

<!-- index.html — dùng cho MỌI route -->
<!DOCTYPE html>
<html>
<head>
  <link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
  <!-- Preload hero image? Chỉ homepage cần... -->
  <!-- Preload search API? Chỉ search page cần... -->
  <!-- Preload checkout SDK? Chỉ checkout cần... -->
  <script src="/app.js" defer></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>

Bạn không thể preload tất cả — sẽ waste bandwidth. Không preload gì cả — LCP chậm vì browser discover resources quá muộn (phải chờ JS parse xong, mount component, mới biết cần fetch gì).

SSR/SSG giải quyết vấn đề này, nhưng migrate SPA lớn sang SSR là project nhiều tháng. Multi-Entry HTML là giải pháp trung gian — hiệu quả 70-80% của SSR nhưng effort chỉ 10%.

Pattern: Nhiều HTML, cùng một app

Ý tưởng đơn giản: thay vì 1 file index.html, tạo nhiều HTML files cho các route chính. Mỗi file có preload/preconnect hints riêng, nhưng đều load cùng SPA bundle.

public/
├── index.html          → fallback cho mọi route khác
├── flights.html        → /flights/*
├── booking.html        → /booking/*
└── checkin.html        → /checkin/*

flights.html — Search page

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Tìm chuyến bay</title>

  <!-- Preconnect tới search API — cần ngay khi page load -->
  <link rel="preconnect" href="https://search-api.example.com">

  <!-- Preload font + critical CSS — giống mọi page -->
  <link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
  <link rel="preload" href="/css/critical.css" as="style">

  <!-- Preload search form chunk — JS cần cho route này -->
  <link rel="preload" href="/js/search-form.chunk.js" as="script">

  <link rel="stylesheet" href="/css/critical.css">
  <script src="/js/app.js" defer></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>

booking.html — Checkout page

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Đặt vé</title>

  <!-- Preconnect tới payment gateway — checkout cần -->
  <link rel="preconnect" href="https://payment.example.com">

  <!-- Preload font + critical CSS -->
  <link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
  <link rel="preload" href="/css/critical.css" as="style">

  <!-- Preload booking chunk + payment SDK -->
  <link rel="preload" href="/js/booking-flow.chunk.js" as="script">
  <link rel="preload" href="https://payment.example.com/sdk.js" as="script">

  <link rel="stylesheet" href="/css/critical.css">
  <script src="/js/app.js" defer></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>

Build pipeline integration

Dùng webpack hoặc Vite plugin để tự sinh HTML files với đúng chunk names:

// vite.config.ts
import { defineConfig } from 'vite';

// Map route → entry config
const routeEntries = {
  index: {
    template: 'templates/index.html',
    preconnect: [],
    preloadChunks: [],
  },
  flights: {
    template: 'templates/flights.html',
    preconnect: ['https://search-api.example.com'],
    preloadChunks: ['search-form'],
  },
  booking: {
    template: 'templates/booking.html',
    preconnect: ['https://payment.example.com'],
    preloadChunks: ['booking-flow'],
  },
};

export default defineConfig({
  plugins: [
    multiEntryHtml(routeEntries),  // Custom plugin
  ],
  build: {
    rollupOptions: {
      input: {
        app: 'src/main.tsx',
      },
    },
  },
});

Custom plugin đọc manifest sau build, inject đúng chunk filenames (với content hash) vào từng HTML template:

function multiEntryHtml(entries: RouteEntries): Plugin {
  return {
    name: 'multi-entry-html',
    writeBundle(options, bundle) {
      const manifest = buildManifest(bundle);

      for (const [name, config] of Object.entries(entries)) {
        const html = generateHtml({
          template: config.template,
          preconnect: config.preconnect,
          preloadScripts: config.preloadChunks
            .map(chunk => manifest.getChunkUrl(chunk))
            .filter(Boolean),
          appScript: manifest.getEntryUrl('app'),
        });

        writeFileSync(`dist/${name}.html`, html);
      }
    },
  };
}

Server routing

Nginx hoặc CDN edge config để serve đúng HTML file:

server {
  listen 80;
  root /var/www/app;

  # Route-specific HTML
  location /flights {
    try_files $uri /flights.html;
  }

  location /booking {
    try_files $uri /booking.html;
  }

  location /checkin {
    try_files $uri /checkin.html;
  }

  # Fallback cho mọi route khác
  location / {
    try_files $uri $uri/ /index.html;
  }

  # Static assets — cache dài
  location /assets/ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
}

Nếu dùng Cloudflare Pages hoặc Workers, tương tự:

// Cloudflare Worker
export default {
  async fetch(request: Request, env: Env) {
    const url = new URL(request.url);
    const path = url.pathname;

    // Map route prefix → HTML file
    const htmlMap: Record<string, string> = {
      '/flights': 'flights.html',
      '/booking': 'booking.html',
      '/checkin': 'checkin.html',
    };

    for (const [prefix, file] of Object.entries(htmlMap)) {
      if (path.startsWith(prefix)) {
        return env.ASSETS.fetch(new Request(`${url.origin}/${file}`));
      }
    }

    // Fallback
    return env.ASSETS.fetch(new Request(`${url.origin}/index.html`));
  },
};

Kết quả thực tế

Trên hệ thống booking, áp dụng multi-entry HTML cho 3 route chính:

Metric Trước Sau Cải thiện
Search page LCP 3.1s 2.2s -29%
Booking page LCP 3.4s 2.0s -41%
Check-in page LCP 2.8s 1.9s -32%

Lý do booking page cải thiện nhiều nhất: preconnect tới payment gateway tiết kiệm 400ms connection setup, preload payment SDK chunk giảm thêm 600ms.

Khi nào nên (và không nên) dùng

Dùng khi: bạn có SPA lớn chưa thể migrate sang SSR, có rõ ràng 3-5 route chính chiếm phần lớn traffic, và mỗi route cần resources khác nhau.

Không dùng khi: bạn đã có SSR/SSG (nó làm tốt hơn rồi), app chỉ có 1-2 routes, hoặc routes quá dynamic (mỗi route cần resource set khác nhau hoàn toàn — lúc đó SSR là câu trả lời đúng).

Pattern này không thay thế SSR — nó là stepping stone cho những team chưa sẵn sàng migrate. 70-80% hiệu quả, 10% effort. Đôi khi trade-off đó là hợp lý.


Có câu hỏi hay góp ý? Reach out trên Twitter hoặc GitHub.