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、その後はスムーズなクライアントサイドナビゲーションが実現されます。
ブログに必要な機能要件
実用的なブログには、以下の機能が不可欠でしょう。
# | 機能 | 目的 | 実装方法 |
---|---|---|---|
1 | Markdown 対応 | 執筆効率化 | front-matter + remark |
2 | 記事一覧表示 | コンテンツ発見性 | loader でファイル一覧取得 |
3 | 検索機能 | 情報アクセス性 | クライアント検索 or API |
4 | タグ分類 | カテゴリー整理 | メタデータ管理 |
5 | OGP 画像 | 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
各パッケージの役割は以下の通りです。
# | パッケージ | 役割 |
---|---|---|
1 | gray-matter | フロントマター解析 |
2 | remark | Markdown パーサー |
3 | remark-html | Markdown → HTML 変換 |
4 | remark-prism | コードのシンタックスハイライト |
5 | rehype-stringify | HTML 文字列化 |
6 | unified | テキスト処理統合 |
7 | @vercel/og | OGP 画像生成 |
ディレクトリ構造の設計
ブログの記事とコードを整理するため、以下のディレクトリ構造を採用します。
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-tutorial | Markdown が HTML に変換されている |
3 | タグ一覧 | http://localhost:3000/blog/tags/Remix | Remix タグの記事のみ表示 |
4 | 検索 | http://localhost:3000/blog?q=Remix | 検索結果が表示される |
5 | OGP 画像 | http://localhost:3000/api/og?title=Test | 画像が生成される |
OGP 画像の確認方法
OGP 画像が正しく生成されているか、以下の方法で確認できます。
ブラウザで直接確認
OGP 画像の URL にアクセスすると、生成された画像が表示されます。
bashhttp://localhost:3000/api/og?title=Remix%20でブログを作る完全ガイド
タイトルが適切にエンコードされ、画像内に表示されることを確認しましょう。
SNS シェア時のプレビュー確認
Twitter や Facebook のシェアデバッガーを使用すると、実際に SNS でどのように表示されるかを確認できます。
- Twitter Card Validator: https://cards-dev.twitter.com/validator
- Facebook Sharing Debugger: https://developers.facebook.com/tools/debug/
本番環境にデプロイ後、これらのツールで記事 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 "<";
case ">": return ">";
case "&": return "&";
case "'": return "'";
case '"': return """;
default: return c;
}
});
}
レスポンスの Content-Type を application/xml
に設定し、1 時間のキャッシュを設定します。
まとめ
Remix を使用したブログ構築では、以下の機能を実装しました。
実装した主要機能
# | 機能 | 技術 | メリット |
---|---|---|---|
1 | Markdown 対応 | gray-matter + remark | 執筆効率が高い |
2 | 記事一覧・詳細 | loader + useLoaderData | SSR で高速表示 |
3 | 検索機能 | useMemo でフィルタ | リアルタイム検索 |
4 | タグ分類 | メタデータ管理 | 記事の整理が容易 |
5 | OGP 画像生成 | @vercel/og | SNS 共有の最適化 |
6 | シンタックスハイライト | remark-prism | コード可読性向上 |
7 | RSS フィード | XML 生成 | 購読の利便性 |
Remix の優位性
Remix を使うことで、以下のような利点が得られました。
loader 関数により、サーバーサイドでのデータ取得が簡潔に記述できます。型安全性が保たれたまま、フロントエンドにデータを渡せるのが魅力ですね。
ファイルベースルーティングにより、URL 構造とファイル構造が一致し、直感的な開発体験が実現されました。
リソースルートを活用することで、OGP 画像生成や RSS フィードなど、HTML 以外のレスポンスも柔軟に扱えます。
今後の拡張案
さらに機能を拡張する場合、以下のような実装が考えられるでしょう。
コメント機能を追加する場合は、Disqus や utterances などの外部サービスを統合できます。データベースを使って独自のコメントシステムを構築することも可能ですね。
記事の閲覧数やいいね機能を実装する場合は、Remix の action 関数と組み合わせて、サーバーサイドでカウントを管理できます。
関連記事の表示機能を追加するには、タグの類似度や公開日の近さから記事をレコメンドするアルゴリズムを実装すると良いでしょう。
全文検索エンジン(Algolia や MeiliSearch)を統合すれば、より高度な検索体験を提供できます。
関連リンク
- article
Remix でブログをゼロから構築:Markdown・検索・タグ・OGP まで実装
- article
Remix でスケーラブルなディレクトリ設計:routes/リソース/ユーティリティ分割
- article
Remix ルーティング早見表:ネスト・可変パラメータ・モーダルルート対応一覧
- article
Remix 最短セットアップ:初期化から初デプロイまで 10 分で完走する手順
- article
Remix と Next.js/Vite/徹底比較:選ぶべきポイントはここだ!
- article
【実測検証】Remix vs Next.js vs Astro:TTFB/LCP/開発体験を総合比較
- article
shadcn/ui で Command Palette を実装:検索・履歴・キーボードショートカット対応
- article
GPT-5 本番運用の SLO 設計:品質(正確性/再現性)・遅延・コストの三点均衡を保つ
- article
Emotion の「変種(variants)」設計パターン:props→ スタイルの型安全マッピング
- article
Remix でブログをゼロから構築:Markdown・検索・タグ・OGP まで実装
- article
Preact でミニブログを 1 日で公開:ルーティング・MDX・SEO まで一気通貫
- article
Electron スクリーンレコーダー/キャプチャツールを desktopCapturer で作る
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来