DEV Community

Hoa T. Nguyen
Hoa T. Nguyen

Posted on

SEO cho React SPA mà KHÔNG CẦN Next.js: Giải pháp VPS Prerendering

Lời mở đầu

Nếu bạn đang xây dựng ứng dụng web với React + Vite, chắc hẳn bạn đã gặp phải vấn đề kinh điển: SEO. Các công cụ tìm kiếm như Google ngày càng tốt hơn trong việc crawl JavaScript, nhưng vẫn còn nhiều hạn chế, đặc biệt là với:

  • Nội dung động load từ API
  • Open Graph tags khi chia sẻ trên Facebook/Twitter
  • Structured data cho rich snippets
  • Speed indexing và Core Web Vitals

Giải pháp phổ biến nhất hiện nay là migrate sang Next.js. Nhưng điều này đồng nghĩa với việc viết lại toàn bộ kiến trúc ứng dụng, tốn rất nhiều thời gian và công sức.

Vậy nếu tôi nói với bạn rằng có thể giữ nguyên React + Vite stack của mình và vẫn có SEO tuyệt vời?


Vấn đề: React SPA và SEO

Tại sao React SPA khó SEO?

Khi Googlebot crawl một React SPA:

Googlebot → Request URL → Nhanh chóng nhận được index.html (gần như rỗng)
                              ↓
                         HTML chỉ chứa <div id="root"></div>
                              ↓
                    Googlebot phải execute JavaScript để thấy nội dung thật
                              ↓
                   Tốn thời gian, tài nguyên, và đôi khi fail
Enter fullscreen mode Exit fullscreen mode

Hậu quả thực tế

Vấn đề Hậu quả
Content không được index Không xuất hiện trên Google Search
Thiếu OG tags Preview khi chia sẻ trên Facebook bị broken
TTFB cao Core Web Vitals kém, ranking giảm
JavaScript errors Bot không thể đọc nội dung

Giải pháp: VPS-Based Prerendering

Ý tưởng cốt lõi

Thay vì render HTML ở server (như Next.js SSR), chúng ta sẽ:

  1. Build-time: Tạo sẵn HTML cho các trang tĩnh (SSG)
  2. Runtime: Khi bot truy cập, dùng headless browser để render HTML và trả về
  3. Caching: Cache kết quả để lần sau trả về ngay lập tức

Lời mở đầu

Nếu bạn đang xây dựng ứng dụng web với React + Vite, chắc hẳn bạn đã gặp phải vấn đề kinh điển: SEO. Các công cụ tìm kiếm như Google ngày càng tốt hơn trong việc crawl JavaScript, nhưng vẫn còn nhiều hạn chế, đặc biệt là với:

  • Nội dung động load từ API
  • Open Graph tags khi chia sẻ trên Facebook/Twitter
  • Structured data cho rich snippets
  • Speed indexing và Core Web Vitals

Giải pháp phổ biến nhất hiện nay là migrate sang Next.js. Nhưng điều này đồng nghĩa với việc viết lại toàn bộ kiến trúc ứng dụng, tốn rất nhiều thời gian và công sức.

Vậy nếu tôi nói với bạn rằng có thể giữ nguyên React + Vite stack của mình và vẫn có SEO tuyệt vời?


Vấn đề: React SPA và SEO

Tại sao React SPA khó SEO?

Khi Googlebot crawl một React SPA:

Googlebot → Request URL → Nhanh chóng nhận được index.html (gần như rỗng)
                              ↓
                         HTML chỉ chứa <div id="root"></div>
                              ↓
                    Googlebot phải execute JavaScript để thấy nội dung thật
                              ↓
                   Tốn thời gian, tài nguyên, và đôi khi fail
Enter fullscreen mode Exit fullscreen mode

Hậu quả thực tế

Vấn đề Hậu quả
Content không được index Không xuất hiện trên Google Search
Thiếu OG tags Preview khi chia sẻ trên Facebook bị broken
TTFB cao Core Web Vitals kém, ranking giảm
JavaScript errors Bot không thể đọc nội dung

Giải pháp: VPS-Based Prerendering

Ý tưởng cốt lõi

Thay vì render HTML ở server (như Next.js SSR), chúng ta sẽ:

  1. Build-time: Tạo sẵn HTML cho các trang tĩnh (SSG)
  2. Runtime: Khi bot truy cập, dùng headless browser để render HTML và trả về
  3. Caching: Cache kết quả để lần sau trả về ngay lập tức

Architecture

                              ┌─────────────────┐
                              │   User Request  │
                              └────────┬────────┘
                                       │
                                       ▼
┌──────────────────────────────────────────────────────────────┐
│                         Nginx Reverse Proxy                   │
│  ┌────────────────────────────────────────────────────────┐  │
│  │              Bot Detection (User-Agent)                │  │
│  └───────────────────────┬────────────────────────────────┘  │
│                          │                                    │
│           ┌──────────────┴──────────────┐                    │
│           ▼                             ▼                     │
│    ┌─────────────┐              ┌─────────────┐             │
│    │  Is Bot?    │              │ Is Human?   │             │
│    └──────┬──────┘              └──────┬──────┘             │
│           │                            │                     │
│           ▼                            ▼                     │
│    ┌─────────────┐              ┌─────────────┐             │
│    │  Prerender  │              │  SPA Files  │             │
│    │  Service    │              │ (index.html)│             │
│    │  (Playwright│              └─────────────┘             │
│    │   + Express)│                                        │
│    └──────┬──────┘                                         │
│           │                                                │
│           ▼                                                │
│    ┌─────────────┐                                         │
│    │ Redis Cache │                                         │
│    └─────────────┘                                         │
└──────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Tech Stack

Frontend (Unchanged):
├── React 18
├── Vite 5
├── React Router
└── react-helmet-async (meta tags)

SEO Components:
├── vite-plugin-prerender (build-time SSG)
├── Playwright (runtime headless browser)
├── Express (prerender server)
└── Redis (optional caching)

Infrastructure:
├── Nginx (reverse proxy + bot detection)
├── PM2 (process manager)
└── Let's Encrypt (SSL)
Enter fullscreen mode Exit fullscreen mode

Implementation Guide

1. Setup React App

Install dependencies

npm install react-helmet-async
npm install -D vite-plugin-prerender @prerenderer/renderer-playwright
Enter fullscreen mode Exit fullscreen mode

SEO Component

Tạo src/components/SEO.jsx:

import { Helmet } from 'react-helmet-async';

const DEFAULT_META = {
  title: 'Your Site Name',
  description: 'Your default description',
  ogImage: '/og-default.jpg',
  twitterCard: 'summary_large_image',
};

export default function SEO({
  title,
  description,
  ogImage,
  canonical = '',
  noindex = false,
  structuredData = null,
}) {
  const fullTitle = title === DEFAULT_META.title
    ? title
    : `${title} | ${DEFAULT_META.title}`;

  return (
    <Helmet>
      <title>{fullTitle}</title>
      <meta name="description" content={description} />
      {noindex && <meta name="robots" content="noindex, nofollow" />}
      {canonical && <link rel="canonical" href={canonical} />}

      {/* Open Graph */}
      <meta property="og:title" content={fullTitle} />
      <meta property="og:description" content={description} />
      <meta property="og:image" content={ogImage} />
      <meta property="og:type" content="website" />

      {/* Twitter */}
      <meta name="twitter:card" content={twitterCard} />
      <meta name="twitter:title" content={fullTitle} />
      <meta name="twitter:description" content={description} />
      <meta name="twitter:image" content={ogImage} />

      {/* Structured Data */}
      {structuredData && (
        <script type="application/ld+json">
          {JSON.stringify(structuredData)}
        </script>
      )}
    </Helmet>
  );
}
Enter fullscreen mode Exit fullscreen mode

Wrap App with HelmetProvider

// src/main.jsx
import { HelmetProvider } from 'react-helmet-async';

ReactDOM.createRoot(document.getElementById('root')).render(
  <HelmetProvider>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </HelmetProvider>
);
Enter fullscreen mode Exit fullscreen mode

Configure Vite for Static Prerendering

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { prerender } from 'vite-plugin-prerender';
import { PlaywrightLauncher } from '@prerenderer/renderer-playwright';

export default defineConfig({
  plugins: [
    react(),
    prerender({
      routes: ['/', '/about', '/contact', '/blog'],
      renderer: new PlaywrightLauncher({
        headless: true,
        renderAfterTime: 2000,
      }),
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

2. VPS Setup

Prerender Service

# On your VPS
mkdir -p /var/www/prerender
cd /var/www/prerender

npm init -y
npm install express playwright redis
npx playwright install chromium
Enter fullscreen mode Exit fullscreen mode
// server.js
import express from 'express';
import { chromium } from 'playwright';

const app = express();
const PORT = 3000;

async function renderPage(url) {
  const browser = await chromium.launch({
    headless: true,
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  });

  try {
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: 'networkidle' });
    await page.waitForTimeout(1000);
    return await page.content();
  } finally {
    await browser.close();
  }
}

app.get('*', async (req, res) => {
  const url = 'https://yourdomain.com' + req.originalUrl;
  const html = await renderPage(url);
  res.send(html);
});

app.listen(PORT, () => {
  console.log(`Prerender running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Start with PM2:

pm2 start server.js --name prerender
pm2 save
pm2 startup
Enter fullscreen mode Exit fullscreen mode

3. Nginx Configuration

# Bot detection
map $http_user_agent $is_bot {
    default 0;
    ~*Googlebot 1;
    ~*Bingbot 1;
    ~*facebookexternalhit 1;
    ~*TwitterBot 1;
    # ... add more bots
}

upstream prerender {
    server localhost:3000;
}

server {
    listen 443 ssl;
    server_name yourdomain.com;

    # SSL config
    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    root /var/www/your-site;
    index index.html;

    # Bot routing
    location / {
        if ($is_bot = 1) {
            proxy_pass http://prerender;
            break;
        }
        try_files $uri $uri/ /index.html;
    }

    # Proxy settings
    location /prerender/ {
        proxy_pass http://prerender;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
    }
}
Enter fullscreen mode Exit fullscreen mode

Google Compliance: Tránh Cloaking Penalty

✅ Nên làm

  • Server cùng nội dung cho bot và user (chỉ khác ở việc đã render hay chưa)
  • Cho phép Googlebot access CSS/JS
  • Dùng meta robots tags thay vì hide content
  • Đảm bảo prerendered HTML match với hydrated React

❌ Không nên làm

  • Show nội dung khác cho bot vs user (cloaking)
  • Block CSS/JS khỏi Googlebot
  • Dùng display: none để hide keyword stuffing
  • Redirect bot khác user

Test cloaking

# Compare responses
curl -A "Googlebot" https://yourdomain.com/page > bot.html
curl -A "Mozilla" https://yourdomain.com/page > user.html
diff bot.html user.html
Enter fullscreen mode Exit fullscreen mode

Performance & Caching

Cache Strategy

Content Redis Cache Nginx Cache CDN
Static pages 24 hours 24 hours 1 day
Dynamic pages 30 min 1 hour 1 hour
API endpoints No No No
JS/CSS assets N/A 1 year 1 year

Core Web Vitals Targets

LCP (Largest Contentful Paint): < 2.5s
FID (First Input Delay): < 100ms
CLS (Cumulative Layout Shift): < 0.1
TTFB (Time to First Byte): < 800ms
Enter fullscreen mode Exit fullscreen mode

Testing & Verification

1. Test Bot Detection

# Should return prerendered HTML
curl -A "Googlebot" https://yourdomain.com/

# Should return SPA
curl -A "Mozilla" https://yourdomain.com/
Enter fullscreen mode Exit fullscreen mode

2. Test Meta Tags

curl -A "Googlebot" https://yourdomain.com/ | grep -E "(<title>|<meta)"
Enter fullscreen mode Exit fullscreen mode

3. Test Structured Data

4. Test Core Web Vitals


So sánh chi tiết với Next.js

Bảng so sánh tổng quan

Tiêu chí VPS Prerendering Next.js SSR Prerender.io
Cost ~$10-20/tháng ~$10-20/tháng $50-400/tháng
Framework lock-in Không Có (Next.js) Không
Setup complexity Trung bình Cao Thấp
Control Full Full Giới hạn
Scaling Manual Auto/Vercel Auto
Vendor dependency Không Vercel (optional)

Phân tích chi tiết

1. Chi phí migrate

Yếu tố VPS Prerendering Next.js
Viết lại code Không (~0 giờ) Có (~100-500+ giờ)
Học framework mới Không Cần học API Routes, App Router
Test lại toàn bộ Không Có, toàn bộ app
Deploy config mới Thêm prerender service Thay đổi pipeline hoàn toàn

2. Rendering Strategy

// VPS Prerendering - Giữ nguyên code React của bạn
// src/pages/Product.jsx
export default function Product() {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    fetch(`/api/products/${id}`)
      .then(r => r.json())
      .then(setProduct);
  }, [id]);

  return product ? <ProductCard {...product} /> : <Loading />;
}

// Next.js SSR - Phải refactor
// app/product/[id]/page.jsx
async function getProduct(id) {
  const res = await fetch(`${API_URL}/products/${id}`);
  return res.json();
}

export default async function ProductPage({ params }) {
  const product = await getProduct(params.id);
  return <ProductCard {...product} />;
}
Enter fullscreen mode Exit fullscreen mode

3. Routing

Tính năng VPS Prerendering Next.js
File-based routing Không (React Router)
Dynamic routes Có (React Router)
Nested layouts Manual Có (Layout component)
Route groups Không
Middleware routing Không

4. Data Fetching

// VPS Prerendering - Use bất kỳ pattern nào
const [data, setData] = useState();
useEffect(() => {
  fetch('/api/data').then(setData); // ✅ Hoạt động
}, []);

// Next.js - Phân biệt SSG vs SSR vs CSR
// SSG
export async function getStaticProps() { ... }

// SSR
export async function getServerSideProps() { ... }

// App Router
async function getData() { ... }  // ✅ SSG/SSR
useEffect(() => { ... })          // ✅ CSR
Enter fullscreen mode Exit fullscreen mode

5. Deployment Options

Platform VPS Prerendering Next.js
VPS tự quản ✅ Full control ✅ Hoạt động
Vercel ❌ Không tối ưu ✅ Native (best)
Netlify ❌ Không tối ưu ✅ Native
Cloudflare Pages ❌ Không tối ưu ✅ Hoạt động
Docker ✅ Easy ✅ Possible

6. Performance

Metric VPS Prerendering Next.js (SSR)
First Paint ~200ms (cached) ~300-500ms
Time to Interactive Fast (SPA) Medium
Server load Chỉ bot request Mọi request
Cache efficiency Rất cao (bot cache) Cao
TTFB (bot) ~100-300ms ~200-500ms

7. Learning Curve

VPS Prerendering:  ▁▂▃▅▆▇ (Trung bình)
├── Bạn đã biết React → 0 learning
├── Bạn đã biết Vite → 0 learning
├── Nginx config → Cần học
└── Playwright → Cần học cơ bản

Next.js:  ▁▂▃▅▆█▂▄▆█ (Dài)
├── App Router → Cần học lại
├── Server Components → Concept mới
├── Data fetching patterns → Nhiều cách
├── Config Vercel → Cần học
└── Migrating existing code → Tốn thời gian
Enter fullscreen mode Exit fullscreen mode

Khi nào nên chọn gì?

Chọn VPS Prerendering khi:

  • ✅ Đã có dự án React SPA đang chạy
  • ✅ Không muốn migrate framework
  • ✅ Team đã quen với React + Vite
  • ✅ Muốn full control infrastructure
  • ✅ Budget hạn chế (~$10-15/tháng)
  • ✅ SEO là requirement nhưng không phải core

Chọn Next.js khi:

  • ✅ Dự án mới bắt đầu từ scratch
  • ✅ SSR là requirement từ đầu
  • ✅ Team sẵn sàng học Next.js
  • ✅ Muốn dùng Vercel ecosystem
  • ✅ Cần features: Image optimization, Font optimization, ISR, etc.
  • ✅ App chủ yếu là content-driven (blog, marketing site)

Migration Path từ VPS Prerendering → Next.js

Một lợi thế lớn: Bạn có thể dùng VPS Prerendering như giải pháp tạm thời, sau đó migrate sang Next.js khi có thời gian:

Phase 1 (Tuần 1-2): Setup VPS Prerendering
├── SEO đã tốt
├── Business chạy tiếp
└── Không cần vội migrate

Phase 2 (Tháng 2-3): Migrate dần từng page
├── Migrate page quan trọng nhất trước
├── Cả 2 system chạy song song
└── Không downtime

Phase 3 (Tháng 4+): Complete migration
├── Switch hoàn toàn sang Next.js
└── Tắt prerender service
Enter fullscreen mode Exit fullscreen mode

Chi phí ước tính

Component Chi phí tháng
VPS (2GB RAM, Hetzner/DigitalOcean) $6-12
Domain $1-2
Cloudflare CDN (Free tier) $0
Tổng ~$10-15/tháng

Kết luận

VPS-based prerendering là giải pháp SEO cho React SPA:

  1. Không cần migrate framework - Giữ nguyên React + Vite
  2. Cost-effective - Chỉ tốn ~$10-15/tháng
  3. Google-compliant - Không lo cloaking penalty
  4. Scalable - Có thể cache và optimize theo nhu cầu
  5. Full control - Chủ động mọi thứ, không依赖 third-party

Đây là giải pháp long-term, stable cho những ai muốn giữ architecture hiện tại mà vẫn có tốt SEO.


Source Code

Toàn bộ source code và documentation có sẵn tại:

GitHub: hoatepdev/VPS-SEO

Cảm ơn đã đọc! Nếu bạn có câu hỏi hoặc muốn thảo luận thêm về React SEO, hãy comment bên dưới nhé.


P/s: Bài viết này được viết dựa trên kinh nghiệm thực tế khi triển khai SEO cho các dự án React SPA. Nếu bạn có giải pháp nào khác hay hơn, đừng ngần ngại chia sẻ nhé!

Top comments (0)