🗂
MDXベース記事管理システムの設計と実装: Cloudflare Pages + Remix環境での画像管理とAIライティング最適化
MDXベースの記事管理システムを構築した際の設計思想と実装の詳細を記録します。特に画像管理、AIライティング最適化、ビルド時処理について詳しく解説します。
🎯 システム要件と課題
解決すべき課題
- AIライティング最適化: ChatGPT/Claude等でのライティング時はMarkdownが最適
- 動的コンテンツ: MDXでReactコンポーネントを使いたい
- 画像管理: アップロード、最適化、配信の効率化
- 開発効率: ローカルでのリアルタイム編集・プレビュー
- 本番最適化: 高速な表示とSEO対応
システム要件
- 開発環境: MDXファイル直接読み込み(リアルタイム反映)
- 本番環境: 事前ビルドしたJSONからの高速読み込み
- 画像処理: Cloudflare Imagesとの連携
- AI対応: Markdown→MDX変換の柔軟性
🏗 アーキテクチャ設計
ファイル構成
├── content/articles/ # MDXファイル群
│ ├── article-1.mdx
│ └── article-2.mdx
├── scripts/
│ └── build-articles.mjs # ビルド時処理スクリプト
├── app/
│ ├── routes/
│ │ ├── articles.$slug.tsx # 記事表示ロジック
│ │ ├── admin.upload.tsx # 画像アップロード
│ │ └── admin.edit.$slug.tsx # エディター
│ └── utils/
│ ├── markdown.ts # 型定義
│ └── markdown.server.ts # サーバーサイド処理
└── public/
└── data/
└── articles.json # ビルド時生成JSON
⚙️ ビルド時処理の詳細
1. MDX → JSON変換処理
scripts/build-articles.mjsでの処理フロー:
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
// MDXファイルを処理してJSONを生成
export async function buildArticles() {
const articlesDir = path.join(process.cwd(), 'content', 'articles');
const files = fs.readdirSync(articlesDir).filter(file => file.endsWith('.mdx'));
console.log(`🚀 Processing ${files.length} MDX files...`);
const articles = {};
for (const file of files) {
const filePath = path.join(articlesDir, file);
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { data: frontmatter, content } = matter(fileContent);
const slug = path.basename(file, '.mdx');
articles[slug] = {
slug,
title: frontmatter.title || slug,
description: frontmatter.description || '',
content: content, // 元のMarkdown
publishedAt: frontmatter.publishedAt || new Date().toISOString().split('T')[0],
modifiedAt: frontmatter.modifiedAt,
category: frontmatter.category || '未分類',
tags: frontmatter.tags || [],
};
}
// articles.json生成
fs.writeFileSync(
path.join(process.cwd(), 'public', 'data', 'articles.json'),
JSON.stringify(articles, null, 2)
);
console.log(`✅ Generated articles.json with ${Object.keys(articles).length} articles`);
}
📝 環境別記事読み込み戦略
開発環境: リアルタイム読み込み
// app/routes/articles.$slug.tsx
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const slug = params.slug;
const url = new URL(request.url);
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
let article: Article;
if (isLocal) {
// 🔥 開発環境: MDXから直接読み込み(リアルタイム)
try {
const { getArticleFromMDX } = await import("~/utils/markdown.server");
article = await getArticleFromMDX(slug);
} catch (error) {
throw new Response("記事が見つかりません", { status: 404 });
}
} else {
// ⚡ 本番環境: 事前ビルドJSONから読み込み(高速)
const res = await fetch(`${url.origin}/data/articles.json`, {
headers: { "cache-control": "no-cache" }
});
const data = await res.json();
article = data[slug];
if (!article) {
throw new Response("記事が見つかりません", { status: 404 });
}
}
return json({ article }, {
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=1800",
"CDN-Cache-Control": "public, s-maxage=1800, stale-while-revalidate=86400",
},
});
};
サーバーサイド処理
// app/utils/markdown.server.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
export async function getArticleFromMDX(slug: string): Promise<Article> {
const contentDir = path.join(process.cwd(), "content", "articles");
const filePath = path.join(contentDir, `${slug}.mdx`);
if (!fs.existsSync(filePath)) {
throw new Error(`Article not found: ${slug}`);
}
const fileContent = fs.readFileSync(filePath, "utf-8");
const { data: frontmatter, content } = matter(fileContent);
return {
slug,
title: frontmatter.title || slug,
description: frontmatter.description || "",
content, // 生のMarkdown
publishedAt: frontmatter.publishedAt || new Date().toISOString().split('T')[0],
modifiedAt: frontmatter.modifiedAt,
category: frontmatter.category || "未分類",
tags: frontmatter.tags || [],
};
}
📸 画像管理戦略
1. 画像アップロード API
// app/routes/admin.upload.tsx
export const action = async ({ request }: ActionFunctionArgs) => {
if (process.env.NODE_ENV === "production") {
throw new Response("Not allowed", { status: 403 });
}
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file || !file.type.startsWith("image/")) {
throw new Response("Invalid file", { status: 400 });
}
// Cloudflare Images APIにアップロード
const uploadFormData = new FormData();
uploadFormData.append("file", file);
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${CLOUDFLARE_IMAGES_TOKEN}`,
},
body: uploadFormData,
}
);
const result = await response.json();
if (!result.success) {
throw new Response("Upload failed", { status: 500 });
}
// 最適化されたURL返却
return json({
url: `https://imagedelivery.net/${CLOUDFLARE_HASH}/${result.result.id}/public`,
variants: {
thumbnail: `https://imagedelivery.net/${CLOUDFLARE_HASH}/${result.result.id}/w=300,h=200`,
medium: `https://imagedelivery.net/${CLOUDFLARE_HASH}/${result.result.id}/w=800,h=600`,
large: `https://imagedelivery.net/${CLOUDFLARE_HASH}/${result.result.id}/w=1200,h=800`,
}
});
};
2. エディター画像統合
// app/routes/admin.edit.$slug.tsx - 画像処理部分
const onUpload = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/admin/upload", {
method: "POST",
body: formData,
});
if (!res.ok) return;
const { url } = await res.json();
insertAtCursor(`\n\n`);
};
// ドラッグ&ドロップ対応
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files);
for (const file of files) {
if (file.type.startsWith('image/')) {
await onUpload(file);
}
}
};
// クリップボード対応
const handlePaste = async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
await onUpload(file);
}
}
}
};
🤖 AIライティング最適化
Markdown → MDX変換戦略
AIツールでのライティング時は純粋なMarkdownが最適ですが、サイトではMDXの機能も使いたい場合の対処法:
// 段階的なMDX機能追加
export function enhanceMarkdownWithMDX(content: string): string {
let enhanced = content;
// 1. 外部リンクをOGPカード化
enhanced = enhanced.replace(
/^https?:\/\/[^\s]+$/gm,
(url) => `<OGPLinkCard url="${url}" />`
);
// 2. 画像の最適化
enhanced = enhanced.replace(
/!\[(.*?)\]\((.*?)\)/g,
(match, alt, src) => {
if (src.includes('imagedelivery.net')) {
return `<OptimizedImage src="${src}" alt="${alt}" />`;
}
return match;
}
);
// 3. コードブロックの強化
enhanced = enhanced.replace(
/```(\w+)\n([\s\S]*?)```/g,
(match, lang, code) => `<CodeBlock language="${lang}">\n${code}\n</CodeBlock>`
);
return enhanced;
}
AIツール用のテンプレート
// AIライティング用テンプレート
---
title: "記事タイトル"
description: "記事の説明"
publishedAt: "2025-08-30"
category: "カテゴリ"
tags: ["タグ1", "タグ2"]
slug: "article-slug"
---
# 記事タイトル
記事の内容をここに書く。
## セクション1
内容...
### サブセクション
- リスト項目1
- リスト項目2
```javascript
// コードブロック
console.log('Hello World');
セクション2
外部リンク: https://example.com
内部リンク: リンクテキスト
## 🎨 エディターUIの改善
### モダンツールバー
```typescript
// ツールバーボタンの実装
const ToolbarButton = ({ onClick, icon, label, shortcut }: {
onClick: () => void;
icon: string;
label: string;
shortcut?: string;
}) => (
<button
type="button"
onClick={onClick}
className="px-3 py-1 bg-gray-600 text-white rounded text-sm hover:bg-gray-700 transition-colors"
title={shortcut ? `${label} (${shortcut})` : label}
>
{icon} {label}
</button>
);
// ツールバー実装
<div className="flex flex-wrap gap-2 mb-2">
<ToolbarButton
onClick={() => fileInputRef.current?.click()}
icon="🖼️"
label="画像"
/>
<ToolbarButton
onClick={() => insertTemplate('## ')}
icon="#"
label="見出し"
shortcut="Ctrl+H"
/>
<ToolbarButton
onClick={() => insertTemplate('**太字**')}
icon="B"
label="太字"
shortcut="Ctrl+B"
/>
<ToolbarButton
onClick={() => insertTemplate('*斜体*')}
icon="I"
label="斜体"
shortcut="Ctrl+I"
/>
<ToolbarButton
onClick={() => insertTemplate('```\nコード\n```\n')}
icon="{}"
label="コード"
shortcut="Ctrl+`"
/>
</div>
リアルタイムプレビュー
// プレビュー部分の実装
<div className="prose prose-sm max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
img: ({ src, alt }) => (
<img
src={src}
alt={alt || ''}
className="max-w-full h-auto rounded border shadow-sm"
loading="lazy"
onError={(e) => {
// エラー時の処理
e.currentTarget.style.display = 'block';
e.currentTarget.style.backgroundColor = '#f3f4f6';
e.currentTarget.innerHTML = `画像を読み込めません<br/><small>${src}</small>`;
}}
/>
),
code: ({ className, children }) => {
const match = /language-(\w+)/.exec(className || '');
return match ? (
<code className={`${className} bg-gray-100 text-sm p-1 rounded`}>
{children}
</code>
) : (
<code className="bg-gray-100 text-sm px-1 py-0.5 rounded font-mono">
{children}
</code>
);
},
}}
>
{text}
</ReactMarkdown>
</div>
🚀 パフォーマンス最適化
1. ビルド時最適化
// package.json scripts
{
"scripts": {
"build": "node scripts/build-articles.mjs && npx remix vite:build",
"dev": "npx remix vite:dev",
"preview": "npx remix vite:preview"
}
}
2. 画像最適化
// 画像URL生成ヘルパー
export function generateImageVariants(baseUrl: string) {
if (!baseUrl.includes('imagedelivery.net')) return { original: baseUrl };
const [base, path] = baseUrl.split('/public');
const basePath = base.replace('/public', '');
return {
thumbnail: `${basePath}/w=300,h=200,fit=cover`,
medium: `${basePath}/w=800,h=600,fit=cover`,
large: `${basePath}/w=1200,h=800,fit=cover`,
original: baseUrl,
};
}
3. MDXコンポーネント遅延読み込み
// 重いコンポーネントの遅延読み込み
const LazyOGPCard = lazy(() => import('~/components/OGPLinkCard'));
const LazyCodeBlock = lazy(() => import('~/components/CodeBlock'));
// MDX内での使用
export const components = {
OGPLinkCard: (props) => (
<Suspense fallback={<div className="animate-pulse h-32 bg-gray-200 rounded" />}>
<LazyOGPCard {...props} />
</Suspense>
),
CodeBlock: (props) => (
<Suspense fallback={<pre className="bg-gray-100 p-4 rounded">{props.children}</pre>}>
<LazyCodeBlock {...props} />
</Suspense>
),
};
📊 運用とモニタリング
ビルド時間の監視
// scripts/build-articles.mjs
console.time('⏱️ Build Duration');
// ビルド処理...
console.timeEnd('⏱️ Build Duration');
console.log(`📈 Memory Usage: ${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB`);
エラーハンドリング
// 記事読み込みエラーの処理
export const loader = async ({ params }: LoaderFunctionArgs) => {
try {
const article = await getArticleFromMDX(params.slug!);
return json({ article });
} catch (error) {
console.error(`Failed to load article: ${params.slug}`, error);
// 開発環境では詳細エラー、本番では汎用エラー
const isDev = process.env.NODE_ENV === 'development';
const errorMessage = isDev ? error.message : '記事が見つかりません';
throw new Response(errorMessage, { status: 404 });
}
};
🎯 今後の改善計画
1. コンテンツ管理強化
- 記事のバージョン管理
- 下書き・公開状態の管理
- タグ・カテゴリの階層化
2. AIライティング支援
- AI生成記事のテンプレート
- 自動タグ付け機能
- SEO最適化提案
3. 画像管理改善
- 画像のリサイズ・圧縮自動化
- WebP/AVIF対応
- 画像のAlt自動生成
4. パフォーマンス向上
- 記事の増分ビルド
- Service Workerでのキャッシュ
- CDN最適化
💡 学んだこと
MDX管理のベストプラクティス
- 開発・本番の使い分け: リアルタイム編集 vs 高速表示
- 画像管理の一元化: Cloudflare Imagesとの連携
- AIツール対応: Markdown→MDX変換の柔軟性
- 段階的な機能追加: 基本Markdown + 必要に応じてMDX機能
技術選択の理由
- MDX: マークダウンの簡潔性 + Reactの柔軟性
- Cloudflare Images: 自動最適化 + グローバル配信
- ビルド時生成: SEO + パフォーマンス最適化
- 環境別最適化: 開発効率 vs 本番パフォーマンス
このシステムにより、AIライティング最適化、開発効率、本番パフォーマンスのすべてを両立できるコンテンツ管理基盤が完成しました。
継続的な改善とコミュニティからのフィードバックを通じて、より良いコンテンツ管理システムを構築していきます。
📋 セットアップ手順書
1. 環境準備
必要なパッケージインストール
# 基本パッケージ
npm install @remix-run/cloudflare @remix-run/react
npm install react react-dom
# MDX処理関連
npm install gray-matter react-markdown rehype-raw
# 開発環境用
npm install --save-dev @types/react @types/react-dom
環境変数設定
# .env.local
CLOUDFLARE_ACCOUNT_ID=your_account_id
CLOUDFLARE_IMAGES_TOKEN=your_images_token
CLOUDFLARE_HASH=your_delivery_hash
2. ディレクトリ構造作成
# フォルダ作成
mkdir -p content/articles
mkdir -p scripts
mkdir -p app/utils
mkdir -p public/data
# 初期ファイル作成
touch scripts/build-articles.mjs
touch app/utils/markdown.ts
touch app/utils/markdown.server.ts
3. 型定義セットアップ
app/utils/markdown.ts
export interface Article {
slug: string;
title: string;
description: string;
content: string;
html?: string;
publishedAt: string;
modifiedAt?: string;
category: string;
tags: string[];
}
export interface OGPData {
title?: string;
description?: string;
image?: string;
siteName?: string;
favicon?: string;
}
4. ビルドスクリプト作成
scripts/build-articles.mjs
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import remarkGfm from 'remark-gfm';
import remarkHtml from 'remark-html';
async function buildArticles() {
const articlesDir = path.join(process.cwd(), 'content', 'articles');
// ディレクトリが存在しない場合は作成
if (!fs.existsSync(articlesDir)) {
fs.mkdirSync(articlesDir, { recursive: true });
}
const files = fs.readdirSync(articlesDir).filter(file => file.endsWith('.mdx'));
if (files.length === 0) {
console.log('⚠️ No MDX files found');
return;
}
console.log(`🚀 Processing ${files.length} MDX files...`);
const articles = {};
for (const file of files) {
try {
console.log(`📄 Processing: ${path.basename(file, '.mdx')}`);
const filePath = path.join(articlesDir, file);
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { data: frontmatter, content } = matter(fileContent);
const slug = path.basename(file, '.mdx');
articles[slug] = {
slug,
title: frontmatter.title || slug,
description: frontmatter.description || '',
content: content,
publishedAt: frontmatter.publishedAt || new Date().toISOString().split('T')[0],
modifiedAt: frontmatter.modifiedAt,
category: frontmatter.category || '未分類',
tags: frontmatter.tags || [],
};
console.log(`✅ Processed: ${slug}`);
} catch (error) {
console.error(`❌ Error processing ${file}:`, error);
}
}
// articles.json生成
const dataDir = path.join(process.cwd(), 'public', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
fs.writeFileSync(
path.join(dataDir, 'articles.json'),
JSON.stringify(articles, null, 2)
);
console.log(`🎉 Successfully processed ${Object.keys(articles).length} articles!`);
console.log(`✅ Generated articles.json with ${Object.keys(articles).length} articles`);
}
// 実行
buildArticles().catch(console.error);
5. サーバーサイド処理作成
app/utils/markdown.server.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import type { Article } from "./markdown";
export async function getArticleFromMDX(slug: string): Promise<Article> {
const contentDir = path.join(process.cwd(), "content", "articles");
const filePath = path.join(contentDir, `${slug}.mdx`);
if (!fs.existsSync(filePath)) {
throw new Error(`Article not found: ${slug}`);
}
const fileContent = fs.readFileSync(filePath, "utf-8");
const { data: frontmatter, content } = matter(fileContent);
return {
slug,
title: frontmatter.title || slug,
description: frontmatter.description || "",
content,
publishedAt: frontmatter.publishedAt || new Date().toISOString().split('T')[0],
modifiedAt: frontmatter.modifiedAt,
category: frontmatter.category || "未分類",
tags: frontmatter.tags || [],
};
}
export async function getAllArticles(): Promise<Record<string, Article>> {
const contentDir = path.join(process.cwd(), "content", "articles");
if (!fs.existsSync(contentDir)) {
return {};
}
const files = fs.readdirSync(contentDir).filter(file => file.endsWith('.mdx'));
const articles: Record<string, Article> = {};
for (const file of files) {
const slug = path.basename(file, '.mdx');
try {
articles[slug] = await getArticleFromMDX(slug);
} catch (error) {
console.warn(`Failed to load article: ${slug}`, error);
}
}
return articles;
}
6. ルート設定
app/routes/articles.$slug.tsx
import type { MetaFunction, LoaderFunctionArgs } from "@remix-run/cloudflare";
import { json } from "@remix-run/cloudflare";
import { useLoaderData } from "@remix-run/react";
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import type { Article } from "~/utils/markdown";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data?.article) {
return [
{ title: "記事が見つかりません | Your Site" },
];
}
const { article } = data;
return [
{ title: `${article.title} | Your Site` },
{ name: "description", content: article.description },
{ name: "keywords", content: article.tags.join(", ") },
{ property: "og:title", content: article.title },
{ property: "og:description", content: article.description },
{ property: "og:type", content: "article" },
];
};
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const slug = params.slug;
if (!slug) {
throw new Response("記事が見つかりません", { status: 404 });
}
const url = new URL(request.url);
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
let article: Article;
if (isLocal) {
// 開発環境: MDXから直接読み込み
try {
const { getArticleFromMDX } = await import("~/utils/markdown.server");
article = await getArticleFromMDX(slug);
} catch (error) {
throw new Response("記事が見つかりません", { status: 404 });
}
} else {
// 本番環境: JSONから読み込み
const res = await fetch(`${url.origin}/data/articles.json`);
if (!res.ok) {
throw new Response("記事データの取得に失敗しました", { status: 500 });
}
const data = await res.json();
article = data[slug];
if (!article) {
throw new Response("記事が見つかりません", { status: 404 });
}
}
return json({ article }, {
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=1800",
"CDN-Cache-Control": "public, s-maxage=1800, stale-while-revalidate=86400",
},
});
};
export default function ArticlePage() {
const { article } = useLoaderData<typeof loader>();
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<article className="prose prose-lg max-w-none">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{article.title}</h1>
<div className="flex items-center gap-4 text-gray-600">
<time dateTime={article.publishedAt}>
{new Date(article.publishedAt).toLocaleDateString('ja-JP')}
</time>
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
{article.category}
</span>
</div>
<div className="flex flex-wrap gap-2 mt-4">
{article.tags.map((tag, index) => (
<span key={index} className="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700">
#{tag}
</span>
))}
</div>
</header>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
>
{article.content}
</ReactMarkdown>
</article>
</div>
);
}
7. package.json設定
package.json
{
"scripts": {
"build": "node scripts/build-articles.mjs && npx remix vite:build",
"dev": "concurrently \"npm:dev:*\"",
"dev:remix": "npx remix vite:dev",
"dev:articles": "nodemon --watch content/articles --ext mdx --exec \"node scripts/build-articles.mjs\"",
"preview": "npx remix vite:preview"
},
"dependencies": {
"@remix-run/cloudflare": "*",
"@remix-run/react": "*",
"react": "*",
"react-dom": "*",
"gray-matter": "*",
"react-markdown": "*",
"rehype-raw": "*"
},
"devDependencies": {
"concurrently": "*",
"nodemon": "*"
}
}
8. 初回記事作成
content/articles/hello-world.mdx
---
title: "Hello World - MDXシステムテスト"
description: "MDXベースのコンテンツ管理システムのテスト記事です。"
publishedAt: "2025-08-30"
category: "テスト"
tags: ["MDX", "テスト", "セットアップ"]
slug: "hello-world"
---
# Hello World
これはMDXシステムのテスト記事です。
## 機能テスト
### マークダウン記法
**太字** と *斜体* のテスト。
- リスト項目1
- リスト項目2
- リスト項目3
### コードブロック
```javascript
console.log('Hello, MDX World!');
画像

リンク
動作確認
このファイルが正しく表示されれば、セットアップは成功です!
### 9. 動作確認手順
```bash
# 1. 依存関係インストール
npm install
# 2. 記事ビルドテスト
node scripts/build-articles.mjs
# 3. 開発サーバー起動
npm run dev
# 4. ブラウザで確認
# http://localhost:5173/articles/hello-world
# 5. 本番ビルドテスト
npm run build
npm run preview
10. トラブルシューティング
よくある問題と解決方法
# パッケージが見つからない場合
npm install gray-matter react-markdown rehype-raw
# TypeScriptエラー
npm install --save-dev @types/node
# ファイルパスエラー(Windows)
# path.join()を使用してクロスプラットフォーム対応
# 権限エラー
chmod +x scripts/build-articles.mjs
デバッグ用ログ追加
// scripts/build-articles.mjs にデバッグログ追加
console.log('📂 Working directory:', process.cwd());
console.log('📁 Articles directory:', articlesDir);
console.log('📄 Found files:', files);
11. 初期設定チェックリスト
- Node.js 18+ インストール済み
- 必要パッケージすべてインストール済み
- 環境変数設定済み(Cloudflare使用時)
- ディレクトリ構造作成済み
- ビルドスクリプト動作確認済み
- 開発サーバー起動確認済み
- テスト記事表示確認済み
- 本番ビルド確認済み
この手順書に従えば、ゼロからMDXベースのコンテンツ管理システムを構築できます!
Discussion