0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【個人開発SaaS】Next.jsに移行せず、Vite SPAをVercelビルド時に@sparticuz/chromiumで静的プリレンダリングした話

0
Last updated at Posted at 2026-09-03

概要

個人開発でタスク管理SaaS(Taski)を開発・運用しています。

フロントエンドは React + Vite の純粋なクライアントサイドSPA(Single Page Application)として構築しています。社内管理ツールや認証後のダッシュボードであればSPAで何の問題もありませんが、サービスを公開して運用していくにつれ、以下の課題に直面しました。

  • ランディングページ(LP)や料金プラン、ブログ記事をGoogle等の検索エンジン(SEO)にしっかりインデックスさせたい
  • Twitter(X)やSlackなどでURLが共有された際、ページ固有のOGP(タイトル・説明文・画像)を正しく展開させたい

よくある解決策は「Next.js(App Router/Pages Router)やRemixにフレームワークを移行する」ですが、既存のSPAコードをSSR/SSG向けに書き直すのは個人開発においてコストが高すぎます。

そこで、「ViteのSPA構成はそのまま維持し、Vercelのビルドプロセス(CI)内でヘッドレスブラウザ(Chromium)を起動して、公開対象のページだけ描画済みHTMLを静的に書き出す(プリレンダリング)」 というアプローチを取りました。

本記事では、VercelのLinuxビルド環境でPuppeteerを動かす際に出会ったエラー(共有ライブラリ不足)と、それを解決した @sparticuz/chromium + puppeteer-core の実装構成、CI/CDを絶対に止めないための防御的設計について共有します。


直面した課題: Vercelのビルド環境で素のPuppeteerが動かない

まずローカル環境(Mac/Windows)で、素の puppeteer を使って「ビルド完了後の dist/ をローカルサーバーで配信し、ブラウザでアクセスしてHTMLを保存する」スクリプトを書きました。ローカルでは何の問題もなく一瞬で静的HTMLが生成されました。

しかし、これをVercelにプッシュしたところ、デプロイ時のビルドログで無情にも以下のようなエラーが発生してビルドが失敗しました。

Error: Failed to launch the browser process!
/vercel/path0/node_modules/puppeteer/.local-chromium/linux-xxx/chrome-linux/chrome: 
error while loading shared libraries: libnspr4.so: cannot open shared object file: No such file or directory

原因: サーバーレス/CIコンテナに必要な共有ライブラリがない

Vercelのビルドコンテナ(Amazon Linux系)は軽量化された環境であり、通常のLinuxデスクトップ環境にあるようなレンダリング用の共有ライブラリ(libnspr4.so, libnss3.so, libatk-1.0.so など)がインストールされていません。

そのため、素の puppeteer がダウンロード・実行しようとする標準のChromeバイナリは起動できず、クラッシュしてしまいます。


解決策: @sparticuz/chromium + puppeteer-core の採用

AWS LambdaやGoogle Cloud Functionsなどのサーバーレス環境でPuppeteerを動かす定番パッケージである @sparticuz/chromium を導入しました。

パッケージ構成

npm install -D @sparticuz/chromium puppeteer-core
  • puppeteer-core: ブラウザバイナリを内包しない、Puppeteerの制御APIのみを提供する軽量版
  • @sparticuz/chromium: サーバーレス環境(Amazon Linuxなど)で動作するように依存ライブラリを静的リンク・最適化してビルドされたChromiumバイナリ

ブラウザ起動コードの書き方

import chromium from '@sparticuz/chromium';
import puppeteer from 'puppeteer-core';

const browser = await puppeteer.launch({
  args: chromium.args,
  defaultViewport: chromium.defaultViewport,
  executablePath: await chromium.executablePath(),
  headless: chromium.headless,
});

これだけで、Vercelのビルドコンテナ上でも追加のライブラリインストール不要でChromiumが正常に起動するようになります。


プリレンダリングの実装全体像

作成したプリレンダリングスクリプト(scripts/prerender.js)の流れは以下のとおりです。

1. 最小限の静的Webサーバーを起動する

Puppeteerからアクセスするために、ビルド直後の dist/ を配信するHTTPサーバーを node:http で立ち上げます。ポートは 0(OSに空いているポートを自動割り当てさせる)を指定します。

ここで重要なのが、SPAフォールバックのエミュレーションです。

import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';

async function startStaticServer(distDir, fallbackHtml) {
  const server = createServer(async (req, res) => {
    try {
      const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
      const filePath = path.join(distDir, urlPath);

      // パストラバーサル防止
      if (!filePath.startsWith(distDir)) {
        res.writeHead(403);
        res.end();
        return;
      }

      // 静的ファイル(JS, CSS, 画像等)が存在すればそれを返す
      const st = await stat(filePath).catch(() => null);
      if (st && st.isFile()) {
        const ext = path.extname(filePath);
        const body = await readFile(filePath);
        res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
        res.end(body);
        return;
      }

      // ファイルが存在しないURL(/pricing や /blog など)は、
      // 本番の vercel.json と同様に元の index.html を返す(SPAフォールバック)
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(fallbackHtml);
    } catch (err) {
      res.writeHead(500);
      res.end(String(err));
    }
  });

  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address();
  return { server, port };
}

このSPAフォールバックがないと、Puppeteerが http://127.0.0.1:${port}/pricing にアクセスした際に404が返ってしまい、Reactのルーティングが起動できません。

2. レンダリング完了の待機処理

SPAでは、HTMLが読み込まれた後、JavaScriptが実行され、さらに初期化処理や認証状態チェックが行われます。

Taskiでは未ログイン時のローディング表示(「アカウント情報を確認中...」)があるため、これが消えるまで待機し、さらに useEffect による <title> や <meta> タグの書き換えを確実にキャッチできるようわずかに待機を挟みます。

async function waitForAppReady(page) {
  try {
    // ローディング表示が消えるのを待つ
    await page.waitForFunction(
      () => !document.body.innerText.includes('アカウント情報を確認中'),
      { timeout: 15000 }
    );
  } catch (_) {
    // タイムアウトしても、その時点の内容をベストエフォートで採用する
  }
  // useEffect(title・metaタグの差し替え等)の反映を少し待つ
  await new Promise((resolve) => setTimeout(resolve, 300));
}

3. レンダリング結果を静的ファイルとして保存

描画後のHTMLを取得し、対象パスに応じたディレクトリを作成して index.html として保存します。

for (const route of ROUTES) {
  const page = await browser.newPage();
  try {
    await page.goto(`http://127.0.0.1:${port}${route.path}`, {
      waitUntil: 'networkidle0',
      timeout: 30000,
    });
    await waitForAppReady(page);
    const html = await page.content();
    results.push({ route, html });
  } finally {
    await page.close();
  }
}

// dist ディレクトリへ書き出し
for (const { route, html } of results) {
  await mkdir(path.dirname(route.outFile), { recursive: true });
  await writeFile(route.outFile, html, 'utf8');
}

Vercelなどのモダンホスティングは、dist/pricing/index.html が存在すれば、/pricing へのリクエストに対してそれを最優先で静的HTMLとして返します(ルーティング設定を追加する必要すらありません)。

そしてクライアント側では、HTMLを受信したブラウザが通常通りReactスクリプトを実行し、シームレスにハイドレーション・クライアントサイドルーティングへと移行します。


CI/CDを絶対に落とさないための防御的設計

個人開発において**「プリレンダリングの失敗のせいで緊急バグ修正のデプロイが落ちる」** という事態は何としても避けなければなりません。

そのため、以下の安全策を徹底しています。

① どんなエラーが起きても exit code 0 でフォールバックする

もしChromiumの起動に失敗したりタイムアウトが起きても、「プリレンダリングされていない通常のSPA」としてビルドを成功させます。

main()
  .then(() => process.exit(0))
  .catch((err) => {
    console.warn('[prerender] プリレンダリングに失敗しましたが、ビルドは続行します:', err);
    process.exit(0); // ビルド全体は止めない
  });

② ウォッチドッグタイマーでビルドの無限ハングを阻止

CI環境でヘッドレスブラウザが何らかの理由でフリーズし、Vercelのビルドタイムアウト(通常45分)までプロセスが掴みっぱなしになるのを防ぐため、独立したウォッチドッグタイマーを仕込んでいます。

const watchdog = setTimeout(() => {
  console.warn('[prerender] 想定時間内に終わらなかったため打ち切ります。ビルドは続行します。');
  process.exit(0);
}, 90_000);
watchdog.unref(); // 他の処理が終わっていればイベントループをブロックしない

③ package.json のビルドスクリプト

{
  "scripts": {
    "build": "tsc && vite build && node scripts/prerender.js"
  }
}

vite build の後にそのままチェーンするだけで、GitHubへのプッシュと連動して自動的に実行されます。


導入して得られた効果

  1. フレームワーク移行(Next.js化)のコストをゼロに抑えられた
    • コードベースは軽量で高速な Vite + React のままで一切変更していません。
  2. SEO・SNSプレビューが完璧に機能
    • Google Search Consoleで確認したところ、クローラーは最初から本文や見出し(h1, h2)が含まれたHTMLを取得できるようになりました。
    • X(Twitter)などのクローラーも、ページごとのOGPメタタグを即座に認識して綺麗なカードを表示してくれます。
  3. ビルド時間への影響は最小限
    • 10ページ程度の静的生成であれば、ビルド時間はプラス20〜30秒程度です。Vercelのビルド時間枠にも余裕で収まっています。

まとめ

SPAのSEOやOGP対策というと「Next.jsやRemixへのリプレイス」が真っ先に挙がりがちですが、既存のアプリ構成や開発体験(Viteの爆速なHMRなど)を維持したまま、ビルド後ステップで静的プリレンダリングを行う方法は、個人開発や中小規模のプロダクトにとって非常に現実的で強力な選択肢です。

Vercel環境特有のライブラリ問題も @sparticuz/chromium を使えば綺麗にクリアできます。SPAのSEO対策に悩んでいる方はぜひ試してみてください。


実際にプリレンダリングされた実例・デモ

この記事で紹介した静的プリレンダリング技術は、開発中のタスク管理ツール Taski のブログや機能比較ページ・各種移行ツールページで実際に稼働しています。ページのソースコードを表示(Ctrl+U / Cmd+Option+U)すると、Vite SPAでありながら完全なHTMLが返ってきている様子を確認いただけます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?