T-CREATOR

Remix でブログをゼロから構築:Markdown・検索・タグ・OGP まで実装

Remix でブログをゼロから構築:Markdown・検索・タグ・OGP まで実装

Remix を使って本格的なブログを一から作りたいと思ったことはありませんか。Markdown によるコンテンツ管理、検索機能、タグ分類、そして SNS 共有時に映える OGP 画像まで、実用的なブログに必要な機能をすべて実装する方法を解説します。この記事では、Remix の強力なデータローディング機能とファイルベースルーティングを最大限に活用し、パフォーマンスとユーザー体験の両立を実現していきましょう。

背景

Remix がブログ構築に適している理由

Remix は Next.js と並ぶモダンな React フレームワークですが、ブログ構築において独自の強みを持っています。サーバーサイドレンダリング(SSR)とクライアントサイドナビゲーションをシームレスに統合し、優れたパフォーマンスを実現できるのです。

Remix の loader 関数を使えば、ページ表示に必要なデータをサーバー側で事前に取得できます。これにより、Markdown ファイルの読み込みやメタデータの解析をビルド時ではなくリクエスト時に行えるため、コンテンツの更新がすぐに反映されますね。

以下の図は、Remix におけるデータフローの全体像を示しています。

mermaidflowchart TB
  user["読者"] -->|ページアクセス| route["Remix ルート"]
  route -->|loader 実行| server["サーバー処理"]
  server -->|ファイル読込| md["Markdown ファイル"]
  server -->|メタデータ解析| meta["フロントマター<br/>パース"]
  meta -->|データ返却| route
  route -->|SSR| html["HTML レンダリング"]
  html -->|配信| user
  user -->|クライアント遷移| route

このアーキテクチャにより、初回アクセス時は高速な SSR、その後はスムーズなクライアントサイドナビゲーションが実現されます。

ブログに必要な機能要件

実用的なブログには、以下の機能が不可欠でしょう。

#機能目的実装方法
1Markdown 対応執筆効率化front-matter + remark
2記事一覧表示コンテンツ発見性loader でファイル一覧取得
3検索機能情報アクセス性クライアント検索 or API
4タグ分類カテゴリー整理メタデータ管理
5OGP 画像SNS 共有最適化動的画像生成

これらの機能を段階的に実装していくことで、読者にとって使いやすく、執筆者にとって管理しやすいブログシステムが完成します。

課題

Markdown ベースのコンテンツ管理の複雑さ

Markdown でブログを管理する際、いくつかの技術的課題に直面するでしょう。

まず、Markdown ファイルのメタデータ(タイトル、日付、タグなど)をどう管理するかという問題があります。ファイル名だけでは情報が足りませんし、ファイル内にメタデータを埋め込む場合は適切なパース処理が必要ですね。

次に、Markdown を HTML に変換する際、シンタックスハイライトやカスタムコンポーネントの挿入など、リッチな表現をどう実現するかという課題もあります。

以下の図は、Markdown 処理パイプラインの構造を示しています。

mermaidflowchart LR
  mdFile["Markdown<br/>ファイル"] -->|読込| parser["フロントマター<br/>パーサー"]
  parser -->|分離| meta["メタデータ<br/>(YAML)"]
  parser -->|分離| content["本文<br/>(Markdown)"]
  content -->|変換| remark["remark<br/>プロセッサ"]
  remark -->|AST 変換| rehype["rehype<br/>プラグイン"]
  rehype -->|出力| html["HTML"]
  meta -->|結合| result["最終データ"]
  html -->|結合| result

この処理パイプラインを適切に構築することで、柔軟なコンテンツ表現が可能になります。

検索・タグ機能の設計選択

検索機能の実装には、複数のアプローチがあります。

クライアントサイド検索は実装が簡単ですが、記事数が増えると初期ロードのデータ量が肥大化してしまいます。一方、サーバーサイド検索は効率的ですが、API エンドポイントの設計や検索アルゴリズムの実装が必要ですね。

タグ機能についても、タグの一覧をどう生成・管理するか、タグページのルーティングをどう設計するかなど、検討すべき点が多くあるでしょう。

OGP 画像の動的生成

SNS でブログ記事がシェアされた際、魅力的な OGP 画像が表示されると、クリック率が大幅に向上します。しかし、記事ごとに手作業で画像を作成するのは非効率的ですよね。

理想的には、記事のタイトルやメタデータから自動的に OGP 画像を生成したいところです。これには、サーバーサイドでの画像レンダリング技術が必要になります。

解決策

プロジェクトの初期セットアップ

まずは Remix プロジェクトを作成し、必要なパッケージをインストールしましょう。

Remix プロジェクトの作成

typescriptnpx create-remix@latest my-blog

プロジェクト作成時のオプションでは、以下を選択してください。

  • TypeScript を使用
  • Remix App Server を選択
  • Git リポジトリの初期化

必要なパッケージのインストール

Markdown 処理と画像生成に必要なパッケージをインストールします。

bashyarn add gray-matter remark remark-html remark-prism rehype-stringify unified
yarn add @vercel/og
yarn add -D @types/node

各パッケージの役割は以下の通りです。

#パッケージ役割
1gray-matterフロントマター解析
2remarkMarkdown パーサー
3remark-htmlMarkdown → HTML 変換
4remark-prismコードのシンタックスハイライト
5rehype-stringifyHTML 文字列化
6unifiedテキスト処理統合
7@vercel/ogOGP 画像生成

ディレクトリ構造の設計

ブログの記事とコードを整理するため、以下のディレクトリ構造を採用します。

perlmy-blog/
├── app/
│   ├── routes/
│   │   ├── _index.tsx          # トップページ
│   │   ├── blog._index.tsx     # 記事一覧
│   │   ├── blog.$slug.tsx      # 個別記事
│   │   ├── blog.tags.$tag.tsx  # タグ別一覧
│   │   └── api.og.tsx          # OGP 画像生成
│   ├── utils/
│   │   ├── markdown.server.ts  # Markdown 処理
│   │   └── search.ts           # 検索ロジック
│   └── styles/
│       └── prism.css           # コードハイライト
└── posts/
    ├── first-post.md
    ├── second-post.md
    └── ...

この構造により、記事ファイル(posts/)とアプリケーションコード(app/)が明確に分離され、管理しやすくなりますね。

Markdown 処理の実装

フロントマターの定義

記事の Markdown ファイルには、以下のようなフロントマターを含めます。

markdown---
title: 'Remix でブログを作る方法'
date: '2025-01-15'
tags: ['Remix', 'React', 'TypeScript']
description: 'Remix を使った本格的なブログの作り方を解説します'
---

# 記事の本文

ここに Markdown で記事を書きます...

フロントマターには、タイトル、日付、タグ、説明文など、記事のメタデータを YAML 形式で記述します。

Markdown パーサーの作成

サーバーサイドで Markdown ファイルを処理するユーティリティを作成しましょう。

typescript// app/utils/markdown.server.ts

import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';

まず、必要なモジュールをインポートします。fs​/​promises は非同期ファイル操作、path はパス処理、matter はフロントマター解析に使用しますね。

typescript// 記事のメタデータ型定義
export interface PostMetadata {
  slug: string;
  title: string;
  date: string;
  tags: string[];
  description: string;
}

// 記事全体の型定義
export interface Post extends PostMetadata {
  content: string;
}

TypeScript の型定義により、コードの安全性と可読性が向上します。

typescript// 記事ディレクトリのパス
const POSTS_PATH = path.join(process.cwd(), "posts");

// すべての記事のメタデータを取得
export async function getAllPosts(): Promise<PostMetadata[]> {
  const files = await fs.readdir(POSTS_PATH);

  const posts = await Promise.all(
    files
      .filter((file) => file.endsWith(".md"))
      .map(async (file) => {
        const slug = file.replace(/\.md$/, "");
        const post = await getPostBySlug(slug);
        return post;
      })
  );

readdir で posts ディレクトリ内のファイル一覧を取得し、.md 拡張子のファイルのみをフィルタリングします。各ファイルから slug(URL で使用する識別子)を抽出していますね。

typescript  // 日付の新しい順にソート
  return posts
    .sort((a, b) => {
      return new Date(b.date).getTime() - new Date(a.date).getTime();
    })
    .map(({ content, ...metadata }) => metadata);
}

記事を日付の降順(新しい順)にソートし、コンテンツを除いたメタデータのみを返却します。

typescript// slug から個別の記事を取得
export async function getPostBySlug(slug: string): Promise<Post> {
  const filePath = path.join(POSTS_PATH, `${slug}.md`);
  const fileContent = await fs.readFile(filePath, "utf-8");

  // フロントマターをパース
  const { data, content } = matter(fileContent);

gray-matter を使ってファイルをパースすると、フロントマター(data)と本文(content)に分離されます。

typescript  return {
    slug,
    title: data.title,
    date: data.date,
    tags: data.tags || [],
    description: data.description || "",
    content,
  };
}

パースしたデータを Post 型に整形して返却します。タグや説明文がない場合のデフォルト値も設定していますね。

Markdown から HTML への変換

次に、Markdown の本文を HTML に変換する処理を実装します。

typescriptimport { unified } from "unified";
import remarkParse from "remark-parse";
import remarkHtml from "remark-html";
import remarkPrism from "remark-prism";

// Markdown を HTML に変換
export async function markdownToHtml(markdown: string): Promise<string> {
  const result = await unified()
    .use(remarkParse) // Markdown をパース
    .use(remarkPrism) // コードブロックにシンタックスハイライト
    .use(remarkHtml, { sanitize: false }) // HTML に変換
    .process(markdown);

unified は、テキスト処理のパイプラインを構築するためのフレームワークです。複数のプラグインを順次適用することで、柔軟な変換処理を実現できます。

typescript  return result.toString();
}

変換結果を文字列として返却すれば、HTML への変換が完了します。

記事一覧ページの実装

記事一覧の loader

Remix の loader 関数で、記事一覧を取得します。

typescript// app/routes/blog._index.tsx

import { json } from '@remix-run/node';
import { useLoaderData, Link } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { getAllPosts } from '~/utils/markdown.server';

Remix の必要な機能と、先ほど作成した getAllPosts 関数をインポートします。

typescriptexport async function loader({ request }: LoaderFunctionArgs) {
  const posts = await getAllPosts();

  // URL からクエリパラメータを取得
  const url = new URL(request.url);
  const searchQuery = url.searchParams.get("q") || "";

loader 関数は、ページ表示前にサーバーサイドで実行されます。URL からクエリパラメータ q を取得し、検索キーワードとして使用しますね。

typescript// 検索フィルタリング
const filteredPosts = searchQuery
  ? posts.filter(
      (post) =>
        post.title
          .toLowerCase()
          .includes(searchQuery.toLowerCase()) ||
        post.description
          .toLowerCase()
          .includes(searchQuery.toLowerCase()) ||
        post.tags.some((tag) =>
          tag
            .toLowerCase()
            .includes(searchQuery.toLowerCase())
        )
    )
  : posts;

検索キーワードがある場合、タイトル、説明文、タグのいずれかに一致する記事のみをフィルタリングします。

typescript  return json({ posts: filteredPosts, searchQuery });
}

フィルタリングした記事と検索キーワードを JSON 形式で返却します。

記事一覧の UI コンポーネント

loader で取得したデータを使って、記事一覧を表示します。

typescriptexport default function BlogIndex() {
  const { posts, searchQuery } = useLoaderData<typeof loader>();

useLoaderData フックで、loader から返却されたデータを取得できます。TypeScript の型推論により、型安全なデータアクセスが可能ですね。

typescript  return (
    <div className="blog-index">
      <h1>記事一覧</h1>

      {/* 検索フォーム */}
      <form method="get" className="search-form">
        <input
          type="search"
          name="q"
          defaultValue={searchQuery}
          placeholder="記事を検索..."
          className="search-input"
        />
        <button type="submit">検索</button>
      </form>

検索フォームは、GET メソッドで送信することで、URL にクエリパラメータとして検索キーワードが含まれます。これにより、検索結果を URL で共有できるようになりますね。

typescript      {/* 記事一覧 */}
      <div className="posts-list">
        {posts.length === 0 ? (
          <p>記事が見つかりませんでした。</p>
        ) : (
          posts.map((post) => (
            <article key={post.slug} className="post-card">
              <Link to={`/blog/${post.slug}`}>
                <h2>{post.title}</h2>
              </Link>
              <p className="post-date">{post.date}</p>
              <p className="post-description">{post.description}</p>

各記事をカード形式で表示します。タイトルは記事詳細ページへのリンクとし、日付と説明文も表示していますね。

typescript              {/* タグ */}
              <div className="post-tags">
                {post.tags.map((tag) => (
                  <Link
                    key={tag}
                    to={`/blog/tags/${tag}`}
                    className="tag"
                  >
                    {tag}
                  </Link>
                ))}
              </div>
            </article>
          ))
        )}
      </div>
    </div>
  );
}

タグは、タグ別一覧ページへのリンクとして表示します。

個別記事ページの実装

記事詳細の loader

個別記事を表示するための loader を実装しましょう。

typescript// app/routes/blog.$slug.tsx

import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import type {
  LoaderFunctionArgs,
  MetaFunction,
} from '@remix-run/node';
import {
  getPostBySlug,
  markdownToHtml,
} from '~/utils/markdown.server';

記事取得と HTML 変換のユーティリティをインポートします。

typescriptexport async function loader({ params }: LoaderFunctionArgs) {
  const { slug } = params;

  if (!slug) {
    throw new Response("Not Found", { status: 404 });
  }

URL パラメータから slug を取得します。slug が存在しない場合は、404 エラーを返却しますね。

typescript  try {
    const post = await getPostBySlug(slug);
    const htmlContent = await markdownToHtml(post.content);

slug に対応する記事を取得し、Markdown を HTML に変換します。

typescript    return json({
      post: { ...post, htmlContent },
    });
  } catch (error) {
    throw new Response("Not Found", { status: 404 });
  }
}

記事が見つからない場合は、404 エラーを返却します。

メタタグと OGP の設定

Remix の meta 関数で、SEO とソーシャル共有のためのメタタグを設定します。

typescriptexport const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data) {
    return [{ title: "記事が見つかりません" }];
  }

  const { post } = data;
  const ogImageUrl = `/api/og?title=${encodeURIComponent(post.title)}`;

OGP 画像は、後ほど実装する API エンドポイントから動的に生成します。タイトルをクエリパラメータとして渡していますね。

typescript  return [
    { title: `${post.title} | My Blog` },
    { name: "description", content: post.description },
    { property: "og:title", content: post.title },
    { property: "og:description", content: post.description },
    { property: "og:image", content: ogImageUrl },
    { property: "og:type", content: "article" },
    { name: "twitter:card", content: "summary_large_image" },
    { name: "twitter:title", content: post.title },
    { name: "twitter:description", content: post.description },
    { name: "twitter:image", content: ogImageUrl },
  ];
};

OpenGraph と Twitter Card のメタタグを設定することで、SNS でシェアされた際に適切な情報が表示されます。

記事詳細の UI コンポーネント

記事の内容を表示するコンポーネントを実装します。

typescriptexport default function BlogPost() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <article className="blog-post">
      <header>
        <h1>{post.title}</h1>
        <p className="post-date">{post.date}</p>

        {/* タグ */}
        <div className="post-tags">
          {post.tags.map((tag) => (
            <Link key={tag} to={`/blog/tags/${tag}`} className="tag">
              {tag}
            </Link>
          ))}
        </div>
      </header>

記事のヘッダー部分で、タイトル、日付、タグを表示します。

typescript      {/* Markdown から変換された HTML */}
      <div
        className="post-content"
        dangerouslySetInnerHTML={{ __html: post.htmlContent }}
      />
    </article>
  );
}

dangerouslySetInnerHTML を使用して、変換された HTML をレンダリングします。信頼できるコンテンツ(自分で書いた Markdown)のみを扱う場合は安全ですね。

タグ別一覧ページの実装

タグ別記事の loader

特定のタグが付いた記事の一覧を表示するための loader を実装します。

typescript// app/routes/blog.tags.$tag.tsx

import { json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getAllPosts } from "~/utils/markdown.server";

export async function loader({ params }: LoaderFunctionArgs) {
  const { tag } = params;

  if (!tag) {
    throw new Response("Not Found", { status: 404 });
  }

URL パラメータからタグ名を取得します。

typescriptconst allPosts = await getAllPosts();

// 指定されたタグを持つ記事のみをフィルタ
const posts = allPosts.filter((post) =>
  post.tags.some(
    (t) => t.toLowerCase() === tag.toLowerCase()
  )
);

すべての記事を取得し、指定されたタグを含む記事のみをフィルタリングします。大文字小文字を区別しない比較を行っていますね。

typescript  return json({ tag, posts });
}

タグ名と、フィルタリングした記事を返却します。

タグ別一覧の UI コンポーネント

タグ別の記事一覧を表示します。

typescriptexport default function TagPosts() {
  const { tag, posts } = useLoaderData<typeof loader>();

  return (
    <div className='tag-posts'>
      <h1>タグ: {tag}</h1>
      <p>{posts.length} 件の記事</p>

      <div className='posts-list'>
        {posts.map((post) => (
          <article key={post.slug} className='post-card'>
            <Link to={`/blog/${post.slug}`}>
              <h2>{post.title}</h2>
            </Link>
            <p className='post-date'>{post.date}</p>
            <p className='post-description'>
              {post.description}
            </p>
          </article>
        ))}
      </div>
    </div>
  );
}

記事一覧ページと同様の UI で、タグに該当する記事を表示します。

OGP 画像の動的生成

OGP 画像生成 API の実装

@vercel​/​og を使用して、記事タイトルから動的に OGP 画像を生成する API を作成します。

typescript// app/routes/api.og.tsx

import { ImageResponse } from "@vercel/og";
import type { LoaderFunctionArgs } from "@remix-run/node";

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const title = url.searchParams.get("title") || "My Blog";

クエリパラメータからタイトルを取得します。タイトルが指定されていない場合は、デフォルト値を使用しますね。

typescript  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          backgroundColor: "#1a202c",
          color: "white",
          fontFamily: "sans-serif",
        }}
      >

OGP 画像のベースとなる div 要素を定義します。背景色はダークテーマを採用していますね。

typescript<div
  style={{
    fontSize: 60,
    fontWeight: 'bold',
    maxWidth: '80%',
    textAlign: 'center',
    lineHeight: 1.2,
  }}
>
  {title}
</div>

記事タイトルを大きく、太字で表示します。

typescript        <div
          style={{
            marginTop: 40,
            fontSize: 30,
            color: "#cbd5e0",
          }}
        >
          My Blog
        </div>
      </div>
    ),

ブログ名を下部に表示し、視覚的なバランスを取ります。

typescript    {
      width: 1200,
      height: 630,
    }
  );
}

OGP 画像の推奨サイズである 1200×630 ピクセルで画像を生成します。Twitter や Facebook で最適に表示されるサイズですね。

検索機能の拡張

クライアントサイド検索の実装

記事一覧ページに、リアルタイム検索機能を追加しましょう。

typescript// app/routes/blog._index.tsx(検索機能を追加)

import { useState, useMemo } from "react";

export default function BlogIndex() {
  const { posts } = useLoaderData<typeof loader>();
  const [searchTerm, setSearchTerm] = useState("");

useState フックで、検索キーワードの状態を管理します。

typescript// 検索結果をメモ化
const filteredPosts = useMemo(() => {
  if (!searchTerm) return posts;

  const term = searchTerm.toLowerCase();
  return posts.filter(
    (post) =>
      post.title.toLowerCase().includes(term) ||
      post.description.toLowerCase().includes(term) ||
      post.tags.some((tag) =>
        tag.toLowerCase().includes(term)
      )
  );
}, [posts, searchTerm]);

useMemo を使用して、検索結果の計算をメモ化します。検索キーワードや記事一覧が変わらない限り、再計算されないため、パフォーマンスが向上しますね。

typescript  return (
    <div className="blog-index">
      <h1>記事一覧</h1>

      {/* リアルタイム検索 */}
      <input
        type="search"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="記事を検索..."
        className="search-input"
      />

入力値が変更されるたびに、searchTerm 状態が更新され、検索結果がリアルタイムで反映されます。

typescript      {/* 検索結果の表示 */}
      <p>{filteredPosts.length} 件の記事</p>

      <div className="posts-list">
        {filteredPosts.map((post) => (
          <article key={post.slug} className="post-card">
            {/* 記事カードの内容は省略 */}
          </article>
        ))}
      </div>
    </div>
  );
}

フィルタリングされた記事のみを表示します。

スタイリングとシンタックスハイライト

Prism.js のスタイル適用

コードブロックのシンタックスハイライトのために、Prism.js のスタイルを追加します。

typescript// app/root.tsx

import type { LinksFunction } from '@remix-run/node';
import prismStyles from '~/styles/prism.css';

export const links: LinksFunction = () => [
  { rel: 'stylesheet', href: prismStyles },
];

Remix の links 関数で、CSS ファイルを読み込みます。

css/* app/styles/prism.css */

/* Prism.js のテーマをインポート */
@import url('https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css');

/* カスタムスタイル */
code[class*='language-'],
pre[class*='language-'] {
  font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
  font-size: 14px;
  line-height: 1.5;
}

pre[class*='language-'] {
  padding: 1.5rem;
  border-radius: 8px;
  overflow-x: auto;
}

Prism.js の Tomorrow Night テーマを使用し、フォントとレイアウトをカスタマイズします。

具体例

サンプル記事の作成

実際に Markdown ファイルを作成して、ブログの動作を確認しましょう。

markdown<!-- posts/remix-blog-tutorial.md -->

---

title: "Remix でブログを作る完全ガイド"
date: "2025-01-20"
tags: ["Remix", "TypeScript", "Markdown"]
description: "Remix を使って、検索機能やタグ、OGP 画像生成まで実装した本格的なブログを作る方法を解説します。"

---

# はじめに

Remix は、モダンな Web 開発に必要な機能をすべて備えたフレームワークです。

# Remix の特徴

## サーバーサイドレンダリング

Remix は、デフォルトで SSR に対応しています。

```typescript
export async function loader() {
  const data = await fetchData();
  return json(data);
}
```

このように、loader 関数でデータを取得できます。

フロントマターに必要なメタデータを記述し、本文には Markdown でコンテンツを記述します。コードブロックも正しく変換され、シンタックスハイライトが適用されますね。

記事の表示確認

開発サーバーを起動して、実装した機能を確認しましょう。

bashyarn dev

以下の URL にアクセスして、各ページの動作を確認できます。

#ページURL確認内容
1記事一覧http://localhost:3000/blogすべての記事が表示される
2個別記事http://localhost:3000/blog/remix-blog-tutorialMarkdown が HTML に変換されている
3タグ一覧http://localhost:3000/blog/tags/RemixRemix タグの記事のみ表示
4検索http://localhost:3000/blog?q=Remix検索結果が表示される
5OGP 画像http://localhost:3000/api/og?title=Test画像が生成される

OGP 画像の確認方法

OGP 画像が正しく生成されているか、以下の方法で確認できます。

ブラウザで直接確認

OGP 画像の URL にアクセスすると、生成された画像が表示されます。

bashhttp://localhost:3000/api/og?title=Remix%20でブログを作る完全ガイド

タイトルが適切にエンコードされ、画像内に表示されることを確認しましょう。

SNS シェア時のプレビュー確認

Twitter や Facebook のシェアデバッガーを使用すると、実際に SNS でどのように表示されるかを確認できます。

本番環境にデプロイ後、これらのツールで記事 URL を入力すると、OGP 画像のプレビューが表示されますね。

パフォーマンス最適化

Remix のビルトイン機能を活用して、パフォーマンスを最適化できます。

リソースルートのキャッシュ

OGP 画像生成は比較的重い処理なので、キャッシュヘッダーを設定しましょう。

typescript// app/routes/api.og.tsx(キャッシュヘッダーを追加)

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const title = url.searchParams.get("title") || "My Blog";

  const imageResponse = new ImageResponse(
    // ... 画像生成の JSX
    { width: 1200, height: 630 }
  );

画像を生成したら、適切なキャッシュヘッダーを追加します。

typescript  // レスポンスヘッダーにキャッシュ設定を追加
  const response = new Response(imageResponse.body, {
    headers: {
      "Content-Type": "image/png",
      "Cache-Control": "public, max-age=31536000, immutable",
    },
  });

  return response;
}

Cache-Control ヘッダーで、画像を 1 年間キャッシュするように設定します。同じタイトルの画像は再生成されず、キャッシュから取得されるため、パフォーマンスが大幅に向上しますね。

Markdown 処理のメモ化

記事の Markdown を HTML に変換する処理も、キャッシュすることで高速化できます。

typescript// app/utils/markdown.server.ts(メモ化を追加)

const htmlCache = new Map<string, string>();

export async function markdownToHtml(markdown: string): Promise<string> {
  // キャッシュに存在すればそれを返す
  if (htmlCache.has(markdown)) {
    return htmlCache.get(markdown)!;
  }

メモリ内のキャッシュで、同じ Markdown の変換結果を再利用します。

typescript  const result = await unified()
    .use(remarkParse)
    .use(remarkPrism)
    .use(remarkHtml, { sanitize: false })
    .process(markdown);

  const html = result.toString();

  // キャッシュに保存
  htmlCache.set(markdown, html);

  return html;
}

変換結果をキャッシュに保存し、次回以降のリクエストで再利用できるようにします。

タグクラウドの実装

すべてのタグを一覧表示する「タグクラウド」を追加しましょう。

typescript// app/utils/markdown.server.ts(タグ取得関数を追加)

export async function getAllTags(): Promise<Array<{ tag: string; count: number }>> {
  const posts = await getAllPosts();

  // タグごとの記事数をカウント
  const tagCounts = new Map<string, number>();

Map を使用して、各タグの出現回数をカウントします。

typescriptposts.forEach((post) => {
  post.tags.forEach((tag) => {
    const currentCount = tagCounts.get(tag) || 0;
    tagCounts.set(tag, currentCount + 1);
  });
});

すべての記事のタグを走査し、出現回数を集計していますね。

typescript  // 配列に変換してソート(記事数の多い順)
  return Array.from(tagCounts.entries())
    .map(([tag, count]) => ({ tag, count }))
    .sort((a, b) => b.count - a.count);
}

タグを記事数の降順にソートして返却します。

タグクラウドコンポーネント

タグクラウドを表示するコンポーネントを作成します。

typescript// app/routes/blog._index.tsx(タグクラウドを追加)

import { getAllTags } from '~/utils/markdown.server';

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const posts = await getAllPosts();
  const tags = await getAllTags();

  // 既存の検索処理...

  return json({ posts: filteredPosts, searchQuery, tags });
}

loader でタグ一覧も取得します。

typescriptexport default function BlogIndex() {
  const { posts, searchQuery, tags } = useLoaderData<typeof loader>();

  return (
    <div className="blog-index">
      <h1>記事一覧</h1>

      {/* タグクラウド */}
      <div className="tag-cloud">
        <h2>タグ</h2>
        {tags.map(({ tag, count }) => (
          <Link
            key={tag}
            to={`/blog/tags/${tag}`}
            className="tag-cloud-item"
            style={{
              fontSize: `${Math.min(2, 1 + count / 5)}rem`,
            }}
          >
            {tag} ({count})
          </Link>
        ))}
      </div>

記事数に応じてフォントサイズを変更することで、視覚的に人気のタグがわかるようにします。

typescript      {/* 検索フォームと記事一覧は省略 */}
    </div>
  );
}

RSS フィードの追加

ブログに RSS フィードを追加すると、読者がフィードリーダーで購読できるようになります。

typescript// app/routes/blog.rss[.]xml.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { getAllPosts } from "~/utils/markdown.server";

export async function loader({ request }: LoaderFunctionArgs) {
  const posts = await getAllPosts();
  const url = new URL(request.url);
  const baseUrl = `${url.protocol}//${url.host}`;

記事一覧を取得し、ベース URL を構築します。

typescript  const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>My Blog</title>
    <link>${baseUrl}/blog</link>
    <description>技術ブログ</description>
    <language>ja</language>
    <atom:link href="${baseUrl}/blog/rss.xml" rel="self" type="application/rss+xml" />

RSS フィードのヘッダー部分を XML 形式で構築します。

typescript    ${posts
      .map(
        (post) => `
    <item>
      <title>${escapeXml(post.title)}</title>
      <link>${baseUrl}/blog/${post.slug}</link>
      <description>${escapeXml(post.description)}</description>
      <pubDate>${new Date(post.date).toUTCString()}</pubDate>
      <guid>${baseUrl}/blog/${post.slug}</guid>
    </item>`
      )
      .join("")}
  </channel>
</rss>`.trim();

各記事を <item> 要素として追加します。XML エスケープを行うことで、特殊文字が正しく処理されますね。

typescript  return new Response(rss, {
    headers: {
      "Content-Type": "application/xml",
      "Cache-Control": "public, max-age=3600",
    },
  });
}

// XML エスケープ関数
function escapeXml(unsafe: string): string {
  return unsafe.replace(/[<>&'"]/g, (c) => {
    switch (c) {
      case "<": return "&lt;";
      case ">": return "&gt;";
      case "&": return "&amp;";
      case "'": return "&apos;";
      case '"': return "&quot;";
      default: return c;
    }
  });
}

レスポンスの Content-Type を application​/​xml に設定し、1 時間のキャッシュを設定します。

まとめ

Remix を使用したブログ構築では、以下の機能を実装しました。

実装した主要機能

#機能技術メリット
1Markdown 対応gray-matter + remark執筆効率が高い
2記事一覧・詳細loader + useLoaderDataSSR で高速表示
3検索機能useMemo でフィルタリアルタイム検索
4タグ分類メタデータ管理記事の整理が容易
5OGP 画像生成@vercel/ogSNS 共有の最適化
6シンタックスハイライトremark-prismコード可読性向上
7RSS フィードXML 生成購読の利便性

Remix の優位性

Remix を使うことで、以下のような利点が得られました。

loader 関数により、サーバーサイドでのデータ取得が簡潔に記述できます。型安全性が保たれたまま、フロントエンドにデータを渡せるのが魅力ですね。

ファイルベースルーティングにより、URL 構造とファイル構造が一致し、直感的な開発体験が実現されました。

リソースルートを活用することで、OGP 画像生成や RSS フィードなど、HTML 以外のレスポンスも柔軟に扱えます。

今後の拡張案

さらに機能を拡張する場合、以下のような実装が考えられるでしょう。

コメント機能を追加する場合は、Disqus や utterances などの外部サービスを統合できます。データベースを使って独自のコメントシステムを構築することも可能ですね。

記事の閲覧数やいいね機能を実装する場合は、Remix の action 関数と組み合わせて、サーバーサイドでカウントを管理できます。

関連記事の表示機能を追加するには、タグの類似度や公開日の近さから記事をレコメンドするアルゴリズムを実装すると良いでしょう。

全文検索エンジン(Algolia や MeiliSearch)を統合すれば、より高度な検索体験を提供できます。

関連リンク