更新情報

【実務レベル】Next.js × microCMSで爆速サイトを構築する完全手順|Webディレクター兼エンジニアが解説

WordPressでサイトを運用していると、「管理画面は慣れているのに表示が重い」「プラグイン更新が怖い」「ヘッドレスにしたいが手順が分からない」といった悩みが出てきます。特に企業サイトやポートフォリオでは、見た目の更新頻度より表示速度と運用の安心感が成果に直結します。

この記事では、フリーランスのWebディレクター兼エンジニアとして、実際にWordPress(CSR)運用からNext.js × microCMS × Vercelへ移行した手順を、そのまま再現できる形で解説します。APIの作り方、データ取得、ISR、画像、死活チェックまで、現場で事故りやすい点も含めて書いています。

読み終えるころには、「何をどの順で作るか」「どこをmicroCMSに任せ、どこをNext.jsで処理するか」が判断できるようになります。

なぜ今、Next.js × microCMSが実務で選ばれるのか

結論から言うと、表示はNext.js、更新はmicroCMS、配信はVercelに分けると、制作側と運用側の両方の負荷が下がります。

SEOと速度を同時に取りにいける

従来のWordPressをCSR(クライアント側でAPIを叩く)で出すと、初回表示が遅く、クローラーにも不利です。Next.jsのSSG / ISRなら、ページはあらかじめHTMLとして生成され、更新後も再生成できます。

  • Core Web Vitalsを改善しやすい
  • 記事URLを静的に出せるので、インデックスされやすい
  • APIキーをブラウザに出さなくてよい

クライアントが「更新しやすい」

microCMSは管理画面が軽く、フィールドを先に設計すれば、担当者はタイトル・本文・画像を入れるだけです。WordPressのようにテーマやプラグインの都合で画面が壊れる、ということが起きにくいです。

実務では「エンジニアが触らなくても更新できること」が、継続案件になるかどうかを分けます。CMS選定は技術趣味ではなく、運用設計です。

構成の役割分担

役割

使うもの

理由

表示・ルーティング

Next.js

SSG/ISR、画像最適化、リダイレクト

記事・実績データ

microCMS

非エンジニアでも更新できる

ホスティング

Vercel

Git連携、ISR、next/imageのCDNキャッシュ

事前に決めておくこと(ここを飛ばすと後で作り直す)

コードを書く前に、APIの形を先に決めます。後からフィールド名を変えると、fetchもページも全部巻き戻ります。

ブログ用API

  • blogs(リスト形式): title / content / eyecatch / category
  • categories(リスト形式): name

公式のブログテンプレートで十分です。slugが無くても、コンテンツIDをURLに使えます。きれいなURLが必要なら、後からslugフィールドを足せばよいです。

制作実績のURL一覧

詳細ページが不要なら、リストAPIは作らずオブジェクト形式で十分です。繰り返しフィールドにURLだけ入れ、キャプチャ画像は表示時に生成します。

  • API名: results(オブジェクト形式)
  • 繰り返し: resultslist
  • 中身: url(テキスト)

以前はスプレッドシートのURLからPuppeteerでキャプチャし、サーバーに保存していました。Vercelではデプロイのたびにファイルが消えるので、この方式は使えません。Webhookで画像を書き戻すのも工数が重いです。実務では「URLだけ保存して、表示時にキャプチャAPIへ流す」が一番軽いです。

構築手順

1. プロジェクトを用意する

pnpm create next-app@latest my-site --typescript --app --eslint --tailwind
cd my-site
pnpm add microcms-js-sdk

.env.local にキーを置きます。Gitにコミットしないでください。

MICROCMS_SERVICE_DOMAIN=your-service
MICROCMS_API_KEY=your-api-key

2. microCMSクライアントを共通化する

ページごとにcreateClientを書くと、環境変数チェックが漏れます。必ず1箇所にまとめます。

import { createClient } from "microcms-js-sdk";

function getClient() {
  const serviceDomain = process.env.MICROCMS_SERVICE_DOMAIN;
  const apiKey = process.env.MICROCMS_API_KEY;

  if (!serviceDomain || !apiKey) {
    throw new Error(
      "MICROCMS_SERVICE_DOMAIN と MICROCMS_API_KEY を設定してください"
    );
  }

  return createClient({ serviceDomain, apiKey });
}

export type Blog = {
  id: string;
  title: string;
  content: string;
  eyecatch?: { url: string; width?: number; height?: number };
  category?: { id: string; name: string };
};

export async function getBlogs() {
  const data = await getClient().getList<Blog>({
    endpoint: "blogs",
    queries: { limit: 10, orders: "-publishedAt" },
  });

  return data;
}

export async function getBlog(id: string) {
  return getClient().getListDetail<Blog>({
    endpoint: "blogs",
    contentId: id,
  });
}

3. 一覧と詳細をISRで出す

App Routerでは、revalidateを付けるだけでISRになります。APIキーはサーバー側に残るので、ブラウザに漏れません。

import Link from "next/link";
import { getBlogs } from "@/lib/microcms";

export const revalidate = 60;

export default async function HomePage() {
  const { contents } = await getBlogs();

  return (
    <main>
      <h1>Blog</h1>
      <ul>
        {contents.map((post) => (
          <li key={post.id}>
            <Link href={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}
import { notFound } from "next/navigation";
import { getBlog, getBlogs } from "@/lib/microcms";

export const revalidate = 60;

export async function generateStaticParams() {
  const { contents } = await getBlogs();
  return contents.map((post) => ({ id: post.id }));
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await getBlog(id).catch(() => null);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

本文はリッチエディタのHTMLなので、dangerouslySetInnerHTMLを使います。microCMS側で変なスクリプトは入りませんが、自分でiframeやカスタムHTMLを許可している場合はXSSに注意してください。

4. 画像は next/image に載せる

microCMSの画像ドメインを許可しないと、ビルドまたは実行時に落ちます。

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.microcms-assets.io",
      },
      {
        protocol: "https",
        hostname: "image.thum.io",
      },
    ],
    minimumCacheTTL: 60 * 60 * 24 * 7,
  },
};

export default nextConfig;

制作実績は「URLだけ」で回す

実績一覧で外部サイトのサムネイルが欲しい場合、画像を手で上げる必要はありません。microCMSにはURLだけ入れ、描画時にキャプチャサービスのURLを組み立てます。

const DEFAULT_CAPTURE_URL =
  "https://image.thum.io/get/width/1200/noanimate/{url}";

export function getCaptureImageUrl(targetUrl: string) {
  const template = process.env.CAPTURE_IMAGE_URL || DEFAULT_CAPTURE_URL;
  return template.replaceAll("{url}", encodeURIComponent(targetUrl));
}
function normalizeWebsiteUrl(url: string) {
  if (/^https?:\/\//i.test(url)) return url;
  return `https://${url}`;
}

初回だけキャプチャ生成で遅く、2回目以降はVercelのnext/imageがCDNにキャッシュします。Webhookで画像を書き戻すより、運用が圧倒的に軽いです。

404やドメイン消滅は表示前にスキップする

公開が終わったサイトを一覧に残すと、キャプチャもリンクも壊れます。ISRのタイミングで生存確認し、死んでいるURLは表示だけ外します。microCMSのエントリは消さなくて大丈夫です。

const SKIP_STATUSES = new Set([404, 410]);

export async function isActiveWebsite(url: string) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 8000);

  try {
    let response = await fetch(url, {
      method: "HEAD",
      redirect: "follow",
      signal: controller.signal,
    });

    if (response.status === 405 || response.status === 501) {
      response = await fetch(url, {
        method: "GET",
        redirect: "follow",
        signal: controller.signal,
      });
    }

    return !SKIP_STATUSES.has(response.status);
  } catch {
    return false;
  } finally {
    clearTimeout(timeoutId);
  }
}

403は「サイトはあるが拒否された」なので残します。5xxも一時障害の可能性があるので、いきなり消さない方が安全です。

現場でハマりがちなポイント

API名の単数・複数を揃える

管理画面では「blog」と呼びたくなりますが、エンドポイントがblogsならコードもblogsです。オブジェクト形式のresultsに対してgetListを叩くと、ビルドが落ちます。URL一覧はget()、記事一覧はgetList()です。

スキーム無しURL

example.com のように https:// が無い値は、fetchもキャプチャも失敗します。入稿時に揃えるか、取得時に補完してください。

プレビューは後回しにしない

公開前確認が無いと、クライアントが「保存したのにサイトに出ない」と混乱します。microCMSのプレビューはdraftKeyをクエリで受け取り、下書きを取得します。ISRの公開キャッシュと混ぜないこと。

export async function getBlogPreview(id: string, draftKey: string) {
  const client = createClient({
    serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN!,
    apiKey: process.env.MICROCMS_API_KEY!,
  });

  return client.getListDetail({
    endpoint: "blogs",
    contentId: id,
    queries: { draftKey },
  });
}

静的エクスポートのままVercelに上げない

output: "export" はレンタルサーバー向けです。VercelでISRやリダイレクトを使うなら外します。distDir: "out"も同様です。

Root Directoryを間違えない

GitHubリポジトリのルートにpackage.jsonがあるなら、VercelのRoot Directoryは空です。ローカルで親フォルダの下に置いていても、リポジトリがその中身だけならnextjsにはしません。

開発・公開で使うツール

この構成を実務で回すなら、ホスティングはVercelが第一選択です。Git連携、プレビューデプロイ、ISR、next/imageのキャッシュまで一 loc で揃います。レンタルサーバーに静的書き出しするより、更新のたびにFTPする必要がありません。

実装面では、CursorのようなAIコーディング環境と、Vercel公式プラグインを入れておくと、デプロイ設定や環境変数の漏れに早く気づけます。ただし、APIキーはチャットに貼らず、.env.localとVercelのEnvironment Variablesだけに置いてください。

  • Vercel: プレビューURLをクライアント確認に使える
  • Cursor: 既存コードに合わせた修正が速い
  • microCMS: 記事と実績URLの更新を非エンジニアに渡せる

公開前チェックリスト

  • microCMSに blogs / categories / results がある
  • Vercelに MICROCMS_SERVICE_DOMAINMICROCMS_API_KEY がある
  • .env.example に本番キーを書いていない
  • public のロゴやフレーム画像がリポジトリに入っている
  • 使っていないPuppeteerを依存から外している(Vercelのインストールが落ちやすい)
  • 旧URLが必要な場合は redirects を書いている

サイトのモダン化・構築のご相談

WordPressからの移行、Next.js × microCMSの新規構築、表示速度の改善まで、設計から実装まで一貫して対応しています。「CMSは担当者に渡したいが、サイトは速くしたい」という場合は特に相性が良いです。

ご相談は、ご相談・お問い合わせフォームからお気軽にお問い合わせください。要件が固まっていなくても、現状の構成を見たうえで移行手順を整理するところからお手伝いできます。