メインコンテンツへスキップ

TanStack Startでポートフォリオを組み直した話

TanStack Start、Cloudflare Workers、microCMS で個人ポートフォリオを組み直した記録です。Next.js を選ばなかった理由、SSR とキャッシュで無料枠に収める設計、SEO の型安全な実装まで、選定の判断基準をまとめました。

このサイトは TanStack Start、Cloudflare Workers、microCMS の 3 つで動いています。フロントエンドは TanStack Start、ホスティングは Cloudflare Workers、ブログ記事は microCMS で管理しています。個人のポートフォリオ兼ブログとして必要な機能だけを揃え、クラウドサービスの無料枠の範囲で運用しています。

この記事では、なぜこの構成にしたのか、どこで詰まったのかを記録として残します。

フロントエンド: TanStack Start

フロントエンドフレームワークには TanStack Start を選びました。個人サイトを作るときは Next.js が候補に挙がりやすいですが、今回は別の選択肢にしています。

理由は 2 つあります。1 つは、Cloudflare Workers のような Node.js 以外のランタイムをターゲットにしたかったこと。Next.js を Workers で動かす方法としては、Cloudflare 公式が案内している @opennextjs/cloudflare(OpenNext)が標準的で、これは実際に問題なく動きます。2026 年 3 月に Cloudflare がリリースした vinext という選択肢もありますが、こちらは公式自身が experimental と明言している段階で、本番運用には早いと判断しました。

もう 1 つの理由は、Vite ベースで最初から SSR を組み立てたかったことです。TanStack Start はファイルベースルーティングと Vite との統合が最初からシームレスで、ルートごとの loader、検索パラメータの型安全な検証、head プロパティでのメタタグ管理が同じ場所にまとまります。Next.js より小さく、必要な機能はだいたい揃っている、という感触です。

ルートごとに SEO を型安全に設定する

TanStack Start の head 関数を使うと、ルートごとにメタタグや構造化データを型安全に組み立てられます。このサイトでは libs/seo.ts に SEO 周りの処理を集約しています。

export function createSeo({
  title,
  description,
  path = "/",
  image,
  type = "website",
  noIndex,
}: SeoOptions = {}) {
  const pageTitle = title ?? DEFAULT_TITLE;
  const pageDescription = description ?? DEFAULT_DESCRIPTION;
  const canonicalUrl = toAbsoluteUrl(path);
  const imageUrl = toAbsoluteUrl(image ?? DEFAULT_OG_IMAGE);

  return {
    meta: [
      { title: pageTitle },
      { name: "description", content: pageDescription },
      { name: "robots", content: noIndex ? "noindex, nofollow" : "index, follow" },
      { property: "og:title", content: pageTitle },
      // ... OGP・Twitter Card など
    ],
    links: [{ rel: "canonical", href: canonicalUrl }],
  };
}

createSeo を呼び出すだけで、OGP タグ、Twitter Card、canonical URL、robots タグをまとめて生成できます。各ページで個別に組み立てるとどうしても抜けが出るので、一元管理にしておくほうが安全です。

ブログ記事ページでは、記事タイトルや概要文に加えて、Schema.org の Article 型 JSON-LD も自動生成しています。

export function createArticleJsonLd(
  article: Article,
  path: string,
  image: string,
): WithContext<SchemaArticle> {
  const publishedDate = article.publishedAt || article.createdAt;
  const updatedDate = article.revisedAt || article.updatedAt;

  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: article.title,
    description: article.description,
    image: [toAbsoluteUrl(image)],
    datePublished: publishedDate,
    dateModified: updatedDate,
    // ...
  };
}

JSON-LD は TanStack Start の head 関数の scripts プロパティに type: "application/ld+json" で渡すと、サーバーサイドレンダリング時に <script> タグとして埋め込まれます。構造化データは検索エンジンに記事の公開日や更新日、著者情報を伝えるためのもので、検索順位を上げるというより、検索エンジンに正しく理解してもらうための土台、という位置付けです。

下書きプレビューと noIndex の自動切り替え

ブログ記事ページでは、microCMS の下書き機能を使ったプレビューに対応しています。dk(microCMS の draftKey を渡すための独自クエリ名)というパラメータを受け取ることで、未公開の記事を確認できます。

export const Route = createFileRoute("/blog/$slug")({
  validateSearch: z.object({
    dk: z.string().optional(),
  }),
  loaderDeps: ({ search }) => ({
    dk: search.dk,
  }),
  loader: ({ params, deps }) =>
    getDetailFn({
      data: {
        contentId: params.slug,
        queries: { draftKey: deps.dk },
      },
    }),
  head: ({ loaderData, params }) => {
    // ...
    noIndex: Boolean(loaderData && params.slug && !loaderData.publishedAt),
  },
});

同時に、未公開記事には自動で noindex, nofollow を設定しています。Zod で dk パラメータを型安全に検証し、loaderDeps を経由して loader に渡すというのは TanStack Start の標準的なパターンです。下書きプレビューの URL がうっかり外に出ても、検索エンジンにインデックスされる心配はありません。

ホスティング: Cloudflare Workers

ホスティングには Cloudflare Workers を選びました。VPS でセルフホストして運用負担を抱えるよりも、安定したマネージドサービスに乗ったほうが、個人サイトの運用としては合理的という判断です。

Workers の Free プランは 10 万リクエスト / 日まで無料で、個人サイトのトラフィック規模であれば余裕で収まります。CPU 時間の制限(1 リクエストあたり 10 ms)は後述する OG 画像生成で問題になりましたが、SSR と microCMS への問い合わせ程度であればほぼ気になりません。

SSR を選んだ理由

ブログやポートフォリオでは静的サイト生成(SSG)が選ばれることも多いですが、このサイトでは SSR にしました。

理由はシンプルで、ブログを更新したら即座に変更を反映したかったからです。SSG だとデータ更新のたびにビルドを発火させる必要があり、個人サイトの運用としては少し面倒に感じました。

SSR にすることで、ブログ更新後すぐに最新の内容が表示されます。ただし、毎回 microCMS にリクエストを飛ばすと microCMS 側の API 呼び出し回数を余分に消費するので、Cloudflare Workers の Cache API を使って microCMS のレスポンスをキャッシュしています。キャッシュヒット時は Workers 側だけで応答できるので、microCMS の利用枠を抑えつつ SSR の利点を維持できます。

なお、Workers の Cache API はデータセンター単位のキャッシュ(global ではない)なので、初回アクセスや別リージョンからのアクセスではキャッシュミスする点には注意が必要です。これは個人サイト規模では実質的な問題にならない範囲です。

OG 画像の長期キャッシュ戦略

ブログ記事ごとに OG 画像を動的に生成していますが、生成結果には長期キャッシュを設定しています。

return new Response(Buffer.from(png), {
  headers: {
    "Cache-Control": "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800",
    "Content-Type": "image/png",
    "X-Content-Type-Options": "nosniff",
  },
});

max-age=3600 でブラウザに 1 時間キャッシュさせ、s-maxage=86400 で CDN に 24 時間キャッシュさせます。s-maxage は CDN などの共有キャッシュ向けで、ブラウザは無視するため、両方を併記することでブラウザと CDN で別々のキャッシュ寿命を設定できます。さらに stale-while-revalidate=604800 で 7 日間は古いキャッシュをそのまま返しつつ、バックグラウンドで再検証します。SNS シェア時の OG 画像取得負荷をかなり減らせます。

OG 画像の生成自体は別記事で詳しく書きますが、Satori と resvg で実装したところ Cloudflare Workers の CPU 時間制限に引っかかり、Worker exceeded resource limits(Error 1102)が頻発しました。Rust/WASM に置き換えたものの期待したほどの改善には至っていない、というのが現状です。この経緯は次の記事にまとめる予定です。

Sitemap の動的分割生成

検索エンジン向けの sitemap も、microCMS の記事数に応じて動的に分割しています。

const totalBlogCount = await getList().then((data) => data.totalCount);

for (let i = 1; i <= Math.ceil(totalBlogCount / 100); i++) {
  links.push({
    url: `${SITE_URL}/sitemaps/blog-${i}.xml`,
  });
}

記事数が増えると sitemap.xml が分割 sitemap へのリンクを自動で増やし、各 blog-{n}.xml には 100 件ずつ記事 URL を出力します。手動で sitemap を更新する必要がなく、記事を書くことだけに集中できます。

ブログデータ: microCMS

ブログ記事の管理には microCMS を使っています。バックエンドや DB を自前で用意する必要がなく、コードベースから記事運用を切り離せます。

API 経由でコンテンツを取得するだけなので、記事を更新してもフロントエンドのデプロイは発生しません。「必要なものだけ選ぶ」という方針に沿った選定です。

目次とシンタックスハイライトの自動生成

microCMS のリッチエディタが返すのは HTML なので、そのまま表示するだけでも形にはなりますが、見出しから目次を自動生成し、コードブロックにはシンタックスハイライトを当てる処理を入れています。

export const formatArticleContent = (richText: string) => {
  const $ = load(richText, null, false);
  const toc: ArticleTocItem[] = [];
  const usedIds = new Map<string, number>();

  $("pre code").each((_, elm) => {
    const lang = $(elm).attr("class");
    const res = highlight($(elm).text(), lang);
    $(elm).html(res.value);
  });

  $("h2, h3").each((index, elm) => {
    const heading = $(elm);
    const text = heading.text().trim();
    if (!text) return;
    const level = elm.tagName === "h2" ? 2 : 3;
    const baseId = currentId || createHeadingId(text, index);
    const count = usedIds.get(baseId) || 0;
    const id = count === 0 ? baseId : `${baseId}-${count + 1}`;

    usedIds.set(baseId, count + 1);
    heading.attr("id", id);
    toc.push({ id, text, level });
  });

  return { html: $.html(), toc };
};

cheerio で HTML をパースし、pre code 要素に highlight.js でハイライトを適用します。同時に h2, h3 を抽出して目次を生成し、見出しの ID が重複しないよう usedIds でカウントを管理しています。同じテキストの見出しが複数あっても安全にアンカーが設定されます。

CMS 側に表示用の処理を寄せるのではなく、フロントエンド側でやることで、データと表現を分離できます。

レスポンシブ画像の最適化

記事のサムネイルや著者画像は、<picture> 要素と srcset で最適化しています。

<picture>
  <source
    type="image/webp"
    media="(max-width: 640px)"
    srcSet={`${data.thumbnail.url}?fm=webp&w=414 1x, ${data.thumbnail.url}?fm=webp&w=414&dpr=2 2x`}
  />
  <source
    type="image/webp"
    srcSet={`${data.thumbnail.url}?fm=webp&fit=crop&w=960&h=504 1x, ${data.thumbnail.url}?fm=webp&fit=crop&w=960&h=504&dpr=2 2x`}
  />
  <img
    src={data.thumbnail.url}
    alt={data.title}
    className="mx-auto mt-10 aspect-video w-full max-w-4xl rounded-(--ds-radius-card) object-cover"
    width={data.thumbnail.width}
    height={data.thumbnail.height}
  />
</picture>

microCMS の画像 API で WebP 変換、サイズ指定、Retina 対応(dpr=2)を行います。モバイルでは幅 414px、デスクトップでは 960px の画像を配信し、帯域を節約しています。個人サイトであっても、画像最適化は読者体験に直結します。

デザインと UI はフレームワーク任せにしない

フレームワークを小さくしても、見た目の品質は落とさないことを前提にしています。デザインシステムは自前で構築しました。

src/styles.css で CSS 変数を定義し、色、影、フォント、角丸などのデザイントークンを一元管理しています。さらに Surface コンポーネントを用意して、カード、パネル、ヒーローセクションなどの共通の見た目を再利用できるようにしています。

export type SurfaceVariant = "panel" | "card" | "white" | "hero";

export const surfaceVariantClass: Record<SurfaceVariant, string> = {
  panel: "ds-panel",
  card: "ds-card",
  white: "ds-white-panel",
  hero: "ds-hero-surface",
};

export default function Surface<T extends React.ElementType = "section">({
  as,
  variant = "panel",
  className = "",
  children,
  ...props
}: SurfaceProps<T>) {
  const Component = as || "section";
  return (
    <Component className={`${surfaceVariantClass[variant]} ${className}`} {...props}>
      {children}
    </Component>
  );
}

Surfaceas プロップで任意の HTML 要素やコンポーネントに切り替えられます。section でも div でも a でも使い回せる作りです。デザイントークンを CSS 変数で管理し、コンポーネントで組み合わせることで、Tailwind のクラスがあちこちに散らばらず、見た目の一貫性が保てます。

アクセシビリティを意識したアニメーション

アニメーションには Motion ライブラリを使っていますが、すべてのコンポーネントで useReducedMotion を併用し、視覚的なモーションが不要なユーザーにはアニメーションを無効化しています。

export function Reveal({ children, className = "", offset = 18, ...props }: RevealProps) {
  const reduced = useReducedMotion();

  return (
    <motion.div
      initial="hidden"
      whileInView="visible"
      viewport={{ once: true, amount: 0.2, margin: "0px 0px -80px 0px" }}
      variants={getRevealVariants(offset, Boolean(reduced))}
      className={className}
      {...props}
    >
      {children}
    </motion.div>
  );
}

useReducedMotion は OS 側の「視差効果を減らす」設定を検知します。個人サイトであっても、アクセシビリティは後回しにせず最初から組み込んでいます。

お問い合わせフォーム: バックエンド不要で動かす

お問い合わせページには、Google Forms と連携したフォームを置いています。

const GOOGLE_FORM_ACTION =
  "https://docs.google.com/forms/d/e/.../formResponse";

await fetch(GOOGLE_FORM_ACTION, {
  method: "POST",
  mode: "no-cors",
  body: formData,
});

mode: "no-cors" で Google Forms のエンドポイントに直接 POST すると、問い合わせ内容がスプレッドシートに溜まっていきます。メール送信サーバーや DB を自前で持つ必要がなく、運用コストを上げずに済みます。

バリデーションには TanStack Form と Zod を組み合わせ、型安全なフォームにしています。

const contactSchema = z.object({
  name: z.string().min(1, "お名前を入力してください"),
  email: z.email("有効なメールアドレスを入力してください"),
  message: z.string().min(20, "20文字以上入力してください").max(1000, "1,000文字以内入力してください"),
});

まとめ: 必要なものだけ選ぶ

個人サイトを作るとき、Next.js が最初の候補になることは多いです。実際、Next.js は OpenNext 経由で Cloudflare Workers にも問題なくデプロイできます。それでも、自分が必要とする機能を整理し直すと、もっと小さい構成で十分なことがあります。

このサイトは、Cloudflare Workers と microCMS の無料枠の範囲で動いています。フレームワークを小さくしても、自前のデザインシステムと CSS 変数で見た目の一貫性は保てますし、SSR とキャッシュを組み合わせれば即時反映と低コストを両立できます。

唯一、当初の想定通りにいかなかったのが OG 画像の動的生成です。Satori と resvg で実装したところ Workers の CPU 時間制限に引っかかり、Rust/WASM に切り替えたものの劇的な改善には至っていません。次回はその試行錯誤を詳しく書く予定です。