「Next.jsでブログを作りたいけど、CMSは使いたくない」「Markdownで記事を増やして、GitHubにpushしたらVercelで自動公開したい」──そんな人向けに、余計な説明を削って“手順だけ”にまとめた構築ガイドです。
このページの通りに進めれば、記事一覧(/)→ 記事詳細(/posts/slug)が動く最小ブログができ、さらに カテゴリ/タグ/検索、SEO最低ライン、Vercel公開、投稿フロー固定まで一気通貫で再現できます。
方針(最初に共有)
この手順書の目的
- このページを見ながら、1から同じブログを再構築できること
書き方のルール
- 抽象論は省略し、今やることだけ書きます
- コマンド・ファイル名・置き場所は そのままコピペで使える形にします
作るもの(完成イメージ)
- Next.js(App Router)
- Markdownで記事管理(DB/CMSなし)
/に記事一覧、/posts/[slug]に記事詳細- GitHub + Vercelで公開
- SEO最低ライン(metadata / OGP / sitemap / robots / RSS / 404/500相当)
1. この手順書で作るもの
仕様(この通りのブログができます)
/:記事一覧/posts/first-post:記事詳細content/posts/*.mdを増やすだけで記事が増える
採用技術
- Next.js(App Router)
- Markdown(frontmatter付き)
- GitHub + Vercel
2. 事前に理解しておく最低限の前提
覚えなくてOKな最低限
- Next.js:React製フレームワーク
- App Router:
src/appのフォルダ構造がURLになる - Markdown:記事データ(ファイル)がそのままコンテンツ
- Vercel:GitHubにpushすると自動でビルド&公開
3. Windowsで開発環境を整える
ゴール
- ターミナルでコマンドが打てる
- Node.js が動く
- VS Code で作業できる
3-1. ターミナルを開く
Windowsキー + X- 「ターミナル(Windows Terminal)」を開く
表示例:
C:\\Users\\ユーザー名>
3-2. Node.js(LTS)をインストール
- ブラウザで
https://nodejs.org/を開く - LTS を選択
.msiをダウンロードして実行- すべて Next で進める
確認(ターミナルで実行):
node -v
npm -v
数字が出ればOKです。
3-3. VS Code をインストール
https://code.visualstudio.com/を開く- ダウンロード → インストール
推奨チェック:
- 「PATH に追加」
- 「右クリックで Code で開く」
3-4. 作業フォルダを作る
エクスプローラーで作成:
C:\\dev\\blog
※ 日本語パスは避けます。
3-5. VS Code でフォルダを開く
- VS Code を起動
- 「フォルダーを開く」
C:\\dev\\blogを選択
3-6. VS Code 内ターミナルを使う
メニュー:
表示 → ターミナル
表示例:
PS C:\\dev\\blog>
4. Next.js プロジェクトを作成する
ゴール
- ブラウザで
http://localhost:3000が表示される
4-1. プロジェクト作成
※ @latest の後ろにスペースが必要です。
npx create-next-app@latest my-blog
4-2. 設定(この通り選ぶ)
- TypeScript:Yes
- ESLint:Yes
- Tailwind:Yes
- src ディレクトリ:Yes
- App Router:Yes
- import alias:No(※使いたいなら後でONでもOK)
4-3. 起動確認
cd my-blog
npm run dev
ブラウザで http://localhost:3000 を開いて初期画面が出ればOKです。
5. Markdownブログの最小構成を作る
ゴール
- Markdown記事が表示される
- 記事一覧 → 記事詳細に遷移できる
5-1. 完成形フォルダ構成
my-blog
├─ src
│ ├─ app
│ │ ├─ layout.tsx
│ │ ├─ globals.css
│ │ ├─ page.tsx
│ │ └─ posts
│ │ └─ [slug]
│ │ └─ page.tsx
│ └─ lib
│ └─ posts.ts
└─ content
└─ posts
└─ first-post.md
5-2. 記事用フォルダを作る
my-blog 直下に作成:
content/posts
5-3. Markdown記事を1つ作る
content/posts/first-post.md
---
title: "はじめての雑記ブログ"
date: "2025-12-22"
description: "AIとNext.jsで雑記ブログを作り始めました"
---
これは最初の記事です。
- AIを使ってブログを作っています
- Next.js + Markdown構成です
- 雑記ブログとして気軽に書いていきます
5-4. 必要ライブラリを入れる
npm install gray-matter remark remark-html
5-5. Markdown読込ロジックを作る(src/lib/posts.ts)
src/lib/posts.ts を作成して丸ごとコピペしてください。
// src/lib/posts.ts
import fsfrom"fs";
import pathfrom"path";
import matterfrom"gray-matter";
import { remark }from"remark";
import htmlfrom"remark-html";
const postsDirectory = path.join(process.cwd(),"content/posts");
/** frontmatter の型(Markdown側の標準) */
exporttypePostFrontMatter = {
title:string;
date:string;// "YYYY-MM-DD" 推奨
description?:string;
category?:string;// Step4で使用(今はなくてもOK)
tags?:string[];// Step4で使用(今はなくてもOK)
};
/** 一覧用(Markdown本文は含めない) */
exporttypePostSummary =PostFrontMatter & {
slug:string;
};
/** 詳細用 */
exporttypePostDetail =PostFrontMatter & {
slug:string;
contentHtml:string;
};
functiongetAllSlugs():string[] {
if (!fs.existsSync(postsDirectory))return [];
const fileNames = fs.readdirSync(postsDirectory);
return fileNames
.filter((name) => name.endsWith(".md"))
.map((fileName) => fileName.replace(/\\.md$/,""));
}
functionreadPostFile(
slug:string
): {data:PostFrontMatter;content:string } |null {
const fullPath = path.join(postsDirectory,`${slug}.md`);
if (!fs.existsSync(fullPath))returnnull;
const fileContents = fs.readFileSync(fullPath,"utf8");
const { data, content } =matter(fileContents);
const rawTags = (dataasany).tags;
const normalizedTags =
Array.isArray(rawTags)
? rawTags.map((t:any) =>String(t).trim()).filter(Boolean)
:typeof rawTags ==="string"
? rawTags.split(",").map((s) => s.trim()).filter(Boolean)
: [];
constfm:PostFrontMatter = {
title:String((dataasany).title ??""),
date:String((dataasany).date ??""),
description: (dataasany).description
?String((dataasany).description)
:undefined,
category: (dataasany).category ?String((dataasany).category) :undefined,
tags: normalizedTags,
};
return {data: fm, content };
}
/** 全記事(date降順) */
exportfunctiongetAllPosts():PostSummary[] {
const slugs =getAllSlugs();
return slugs
.map((slug) => {
const parsed =readPostFile(slug);
if (!parsed)returnnull;
return {
slug,
...parsed.data,
}satisfiesPostSummary;
})
.filter((p): p isPostSummary => p !==null)
.sort((a, b) => (a.date < b.date ?1 : -1));
}
/** 単記事(HTML変換込み) */
exportasyncfunctiongetPost(slug:string):Promise<PostDetail> {
const parsed =readPostFile(slug);
if (!parsed) {
return {
slug,
title:"記事が見つかりません",
date:"",
description:"",
category:"",
tags: [],
contentHtml:"<p>Markdown ファイルが存在しません。</p>",
};
}
const processed =awaitremark().use(html).process(parsed.content);
const contentHtml = processed.toString();
return {
slug,
contentHtml,
...parsed.data,
};
}
5-6. トップページ(記事一覧)を作る(src/app/page.tsx)
src/app/page.tsx を丸ごと置き換えます。
// src/app/page.tsx
importLinkfrom"next/link";
import { getAllPosts }from"@/lib/posts";
exportdefaultfunctionHomePage() {
const posts =getAllPosts();
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">雑記ブログ</h1>
<pclassName="mt-2 text-sm text-gray-600">
Next.js(App Router)+ Markdown で作る雑記ブログです。
</p>
{posts.length === 0 ? (
<divclassName="mt-6 rounded border p-4">
<pclassName="text-sm">
まだ記事がありません。<codeclassName="px-1">content/posts</code> に
Markdown を追加してください。
</p>
</div>
) : (
<ulclassName="mt-6 space-y-4">
{posts.map((p) => (
<likey={p.slug}className="rounded border p-4 hover:bg-gray-50">
<Linkhref={`/posts/${p.slug}`}>
<h2className="text-lg font-semibold">{p.title}</h2>
</Link>
<divclassName="mt-1 text-xs text-gray-600">{p.date}</div>
{p.description ? (
<pclassName="mt-2 text-sm text-gray-700">{p.description}</p>
) : null}
<divclassName="mt-3">
<Linkhref={`/posts/${p.slug}`}className="text-sm underline">
記事を読む →
</Link>
</div>
</li>
))}
</ul>
)}
</main>
);
}
5-7. 記事詳細ページを作る(src/app/posts/[slug]/page.tsx)
フォルダとファイルをこの順で作ります。
src/app/posts/src/app/posts/[slug]/src/app/posts/[slug]/page.tsx
中身をコピペ:
// src/app/posts/[slug]/page.tsx
import { getPost }from"@/lib/posts";
typePageProps = {
params: {slug:string };
};
exportdefaultasyncfunctionPostPage({ params }: PageProps) {
const post =awaitgetPost(params.slug);
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">{post.title}</h1>
<divclassName="mt-2 text-xs text-gray-600">{post.date}</div>
<article
className="prose prose-neutral mt-8 max-w-none"
dangerouslySetInnerHTML={{__html:post.contentHtml }}
/>
</main>
);
}
5-8. Tailwindを効かせるためにglobals.cssを作る
場所は固定です:src/app/globals.css
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
html,body {
height:100%;
}
5-9. layout.tsx を最低限正しくする(src/app/layout.tsx)
src/app/layout.tsx を丸ごと置き換えます。
// src/app/layout.tsx
import"./globals.css";
importLinkfrom"next/link";
exportconst metadata = {
title: {
default:"雑記ブログ",
template:"%s | 雑記ブログ",
},
description:"Next.js + Markdown で作る雑記ブログ",
};
exportdefaultfunctionRootLayout({ children }: { children: React.ReactNode }) {
return (
<htmllang="ja">
<bodyclassName="min-h-screen bg-white text-gray-900">
<headerclassName="border-b">
<divclassName="mx-auto max-w-5xl px-4 py-4">
<divclassName="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Linkhref="/"className="text-xl font-bold">
雑記ブログ
</Link>
<navclassName="flex flex-wrap gap-4 text-sm">
<Linkhref="/"className="hover:underline">Home</Link>
<Linkhref="/categories"className="hover:underline">Categories</Link>
<Linkhref="/tags"className="hover:underline">Tags</Link>
<Linkhref="/search"className="hover:underline">Search</Link>
<Linkhref="/about"className="hover:underline">About</Link>
<Linkhref="/contact"className="hover:underline">Contact</Link>
<Linkhref="/privacy"className="hover:underline">Privacy</Link>
</nav>
</div>
</div>
</header>
<mainclassName="mx-auto max-w-5xl px-4 py-8">{children}</main>
<footerclassName="border-t">
<divclassName="mx-auto max-w-5xl px-4 py-6 text-sm text-gray-600">
<p>© {new Date().getFullYear()} 雑記ブログ</p>
</div>
</footer>
</body>
</html>
);
}
5-10. 動作確認(ここがチェックポイント)
開発サーバー起動:
npm run dev
確認:
http://localhost:3000/→ 記事一覧が出るhttp://localhost:3000/posts/first-post→ first-post.md が表示される
5-11. 詰まりやすいポイント(先に潰す)
A. @/lib/posts が解決できない
tsconfig.json に paths が無い場合は、下記を compilerOptions 内に追加します。
{
"compilerOptions":{
"baseUrl":".",
"paths":{
"@/*":["src/*"]
}
}
}
B. app と src/app が同時にある
src/app を採用しているので、app が残っていたら(不要なら)削除します。
C. ルーティングが変(キャッシュが怪しい)
.next を消して再起動します。
Remove-Item-Recurse-Force .next
6. ブログとして成立させる(カテゴリ・タグ・検索)
ゴール
- 記事を探せる(カテゴリ・タグ・検索)
- ナビで回遊できる
- Markdownを唯一のデータソースとして運用できる
6-1. frontmatter ルール(これを標準にする)
例:content/posts/first-post.md を更新してOKです。
---
title: "はじめての雑記ブログ"
date: "2025-12-22"
description: "AIとNext.jsで雑記ブログを作り始めました"
category: "開発"
tags: ["Next.js", "Markdown"]
---
本文...
6-2. posts.ts にカテゴリ/タグ/検索用関数を追加
src/lib/posts.ts の末尾に追記します。
/** カテゴリ別の記事一覧(date降順) */
exportfunctiongetPostsByCategory(category:string) {
const decoded =decodeURIComponent(category);
returngetAllPosts().filter((p) => (p.category ??"") === decoded);
}
/** タグ別の記事一覧(date降順) */
exportfunctiongetPostsByTag(tag:string) {
const decoded =decodeURIComponent(tag);
returngetAllPosts().filter((p) => (p.tags ?? []).includes(decoded));
}
/** カテゴリ一覧(件数付き・件数降順) */
exportfunctiongetAllCategories(): {category:string;count:number }[] {
const map =newMap<string,number>();
for (const pofgetAllPosts()) {
const c = (p.category ??"").trim();
if (!c)continue;
map.set(c, (map.get(c) ??0) +1);
}
returnArray.from(map.entries())
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count || a.category.localeCompare(b.category,"ja"));
}
/** タグ一覧(件数付き・件数降順) */
exportfunctiongetAllTags(): {tag:string;count:number }[] {
const map =newMap<string,number>();
for (const pofgetAllPosts()) {
for (const tof p.tags ?? []) {
const tag =String(t).trim();
if (!tag)continue;
map.set(tag, (map.get(tag) ??0) +1);
}
}
returnArray.from(map.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag,"ja"));
}
/** 検索(title/description 部分一致・大文字小文字無視) */
exportfunctionsearchPosts(query:string) {
const q = (query ??"").trim().toLowerCase();
if (!q)return [];
returngetAllPosts().filter((p) => {
const title = (p.title ??"").toLowerCase();
const desc = (p.description ??"").toLowerCase();
return title.includes(q) || desc.includes(q);
});
}
6-3. /categories カテゴリ一覧ページ
src/app/categories/page.tsx
importLinkfrom"next/link";
import { getAllCategories }from"@/lib/posts";
exportconst metadata = {
title:"カテゴリ一覧",
description:"カテゴリ別に記事を一覧できます。",
};
exportdefaultfunctionCategoriesPage() {
const categories =getAllCategories();
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">カテゴリ一覧</h1>
<pclassName="mt-2 text-sm text-gray-600">カテゴリ別に記事を探せます。</p>
{categories.length === 0 ? (
<divclassName="mt-6 rounded border p-4">
<pclassName="text-sm">
まだカテゴリがありません。frontmatter に{" "}
<codeclassName="px-1">category</code> を追加してください。
</p>
</div>
) : (
<ulclassName="mt-6 space-y-3">
{categories.map(({ category, count }) => (
<likey={category}>
<Link
href={`/categories/${encodeURIComponent(category)}`}
className="flex items-center justify-between rounded border px-4 py-3 hover:bg-gray-50"
>
<spanclassName="font-medium">{category}</span>
<spanclassName="text-sm text-gray-600">{count}件</span>
</Link>
</li>
))}
</ul>
)}
<divclassName="mt-10">
<Linkhref="/"className="text-sm underline">← トップへ戻る</Link>
</div>
</main>
);
}
6-4. /categories/[category] カテゴリ別記事一覧
src/app/categories/[category]/page.tsx
importLinkfrom"next/link";
import { getPostsByCategory, getAllCategories }from"@/lib/posts";
typePageProps = {
params: {category:string };
};
exportasyncfunctiongenerateStaticParams() {
returngetAllCategories().map((c) => ({
category:encodeURIComponent(c.category),
}));
}
exportdefaultfunctionCategoryPostsPage({ params }: PageProps) {
const category =decodeURIComponent(params.category);
const posts =getPostsByCategory(params.category);
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">カテゴリ:{category}</h1>
<pclassName="mt-2 text-sm text-gray-600">このカテゴリの記事一覧です。</p>
{posts.length === 0 ? (
<divclassName="mt-6 rounded border p-4">
<pclassName="text-sm">このカテゴリの記事はまだありません。</p>
</div>
) : (
<ulclassName="mt-6 space-y-4">
{posts.map((post) => (
<likey={post.slug}className="rounded border p-4 hover:bg-gray-50">
<Linkhref={`/posts/${post.slug}`}>
<h2className="text-lg font-semibold">{post.title}</h2>
</Link>
<divclassName="mt-1 text-xs text-gray-600">{post.date}</div>
{post.description ? (
<pclassName="mt-2 text-sm text-gray-700">{post.description}</p>
) : null}
<divclassName="mt-3">
<Linkhref={`/posts/${post.slug}`}className="text-sm underline">
記事を読む →
</Link>
</div>
</li>
))}
</ul>
)}
<divclassName="mt-10 flex gap-4">
<Linkhref="/categories"className="text-sm underline">← カテゴリ一覧へ</Link>
<Linkhref="/"className="text-sm underline">トップへ戻る</Link>
</div>
</main>
);
}
6-5. /tags タグ一覧ページ
src/app/tags/page.tsx
importLinkfrom"next/link";
import { getAllTags }from"@/lib/posts";
exportconst metadata = {
title:"タグ一覧",
description:"タグ別に記事を一覧できます。",
};
exportdefaultfunctionTagsPage() {
const tags =getAllTags();
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">タグ一覧</h1>
<pclassName="mt-2 text-sm text-gray-600">タグ別に記事を探せます。</p>
{tags.length === 0 ? (
<divclassName="mt-6 rounded border p-4">
<pclassName="text-sm">
まだタグがありません。frontmatter に{" "}
<codeclassName="px-1">tags</code> を追加してください。
</p>
</div>
) : (
<ulclassName="mt-6 space-y-3">
{tags.map(({ tag, count }) => (
<likey={tag}>
<Link
href={`/tags/${encodeURIComponent(tag)}`}
className="flex items-center justify-between rounded border px-4 py-3 hover:bg-gray-50"
>
<spanclassName="font-medium">{tag}</span>
<spanclassName="text-sm text-gray-600">{count}件</span>
</Link>
</li>
))}
</ul>
)}
<divclassName="mt-10 flex gap-4">
<Linkhref="/"className="text-sm underline">← トップへ戻る</Link>
<Linkhref="/categories"className="text-sm underline">カテゴリ一覧へ →</Link>
</div>
</main>
);
}
6-6. /tags/[tag] タグ別記事一覧
src/app/tags/[tag]/page.tsx
importLinkfrom"next/link";
import { getAllTags, getPostsByTag }from"@/lib/posts";
typePageProps = {
params: {tag:string };
};
exportasyncfunctiongenerateStaticParams() {
returngetAllTags().map((t) => ({
tag:encodeURIComponent(t.tag),
}));
}
exportdefaultfunctionTagPostsPage({ params }: PageProps) {
const tag =decodeURIComponent(params.tag);
const posts =getPostsByTag(params.tag);
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">タグ:{tag}</h1>
<pclassName="mt-2 text-sm text-gray-600">このタグの記事一覧です。</p>
{posts.length === 0 ? (
<divclassName="mt-6 rounded border p-4">
<pclassName="text-sm">このタグの記事はまだありません。</p>
</div>
) : (
<ulclassName="mt-6 space-y-4">
{posts.map((post) => (
<likey={post.slug}className="rounded border p-4 hover:bg-gray-50">
<Linkhref={`/posts/${post.slug}`}>
<h2className="text-lg font-semibold">{post.title}</h2>
</Link>
<divclassName="mt-1 text-xs text-gray-600">{post.date}</div>
{post.description ? (
<pclassName="mt-2 text-sm text-gray-700">{post.description}</p>
) : null}
<divclassName="mt-3 flex flex-wrap gap-3">
<Linkhref={`/posts/${post.slug}`}className="text-sm underline">
記事を読む →
</Link>
{post.category ? (
<Link
href={`/categories/${encodeURIComponent(post.category)}`}
className="text-sm underline"
>
カテゴリ:{post.category}
</Link>
) : null}
</div>
</li>
))}
</ul>
)}
<divclassName="mt-10 flex gap-4">
<Linkhref="/tags"className="text-sm underline">← タグ一覧へ</Link>
<Linkhref="/"className="text-sm underline">トップへ戻る</Link>
</div>
</main>
);
}
6-7. /search?q=xxx 検索ページ
src/app/search/page.tsx
importLinkfrom"next/link";
import { searchPosts }from"@/lib/posts";
exportconst metadata = {
title:"検索",
description:"記事タイトル・説明文から検索できます。",
};
typePageProps = {
searchParams?: {q?:string };
};
exportdefaultfunctionSearchPage({ searchParams }: PageProps) {
const q = (searchParams?.q ??"").trim();
const results = q ?searchPosts(q) : [];
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">検索</h1>
<pclassName="mt-2 text-sm text-gray-600">
タイトル / 説明文(description)から簡易検索できます。
</p>
<formmethod="GET"action="/search"className="mt-6 flex gap-2">
<input
name="q"
defaultValue={q}
placeholder="例:Next.js / Markdown / 運用 など"
className="w-full rounded border px-3 py-2 text-sm"
/>
<buttontype="submit"className="rounded border px-4 py-2 text-sm hover:bg-gray-50">
検索
</button>
</form>
{q === "" ? (
<divclassName="mt-6 rounded border p-4">
<pclassName="text-sm text-gray-700">キーワードを入力して検索してください。</p>
</div>
) : results.length === 0 ? (
<divclassName="mt-6 rounded border p-4">
<pclassName="text-sm text-gray-700">
「<spanclassName="font-semibold">{q}</span>」に一致する記事は見つかりませんでした。
</p>
</div>
) : (
<divclassName="mt-6">
<pclassName="text-sm text-gray-600">
「<spanclassName="font-semibold">{q}</span>」の検索結果:{results.length}件
</p>
<ulclassName="mt-4 space-y-4">
{results.map((post) => (
<likey={post.slug}className="rounded border p-4 hover:bg-gray-50">
<Linkhref={`/posts/${post.slug}`}>
<h2className="text-lg font-semibold">{post.title}</h2>
</Link>
<divclassName="mt-1 text-xs text-gray-600">{post.date}</div>
{post.description ? (
<pclassName="mt-2 text-sm text-gray-700">{post.description}</p>
) : null}
<divclassName="mt-3 flex gap-3">
<Linkhref={`/posts/${post.slug}`}className="text-sm underline">
記事を読む →
</Link>
{post.category ? (
<Link
href={`/categories/${encodeURIComponent(post.category)}`}
className="text-sm underline"
>
カテゴリ:{post.category}
</Link>
) : null}
</div>
</li>
))}
</ul>
</div>
)}
<divclassName="mt-10 flex gap-4">
<Linkhref="/"className="text-sm underline">← トップへ戻る</Link>
<Linkhref="/categories"className="text-sm underline">カテゴリ一覧へ →</Link>
</div>
</main>
);
}
6-8. 固定ページ(About / Contact / Privacy)
✏️/about
src/app/about/page.tsx
// src/app/about/page.tsx
importLinkfrom"next/link";
exportconst metadata = {
title:"About",
description:"このブログについて",
};
exportdefaultfunctionAboutPage() {
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">About</h1>
<pclassName="mt-2 text-sm text-gray-600">このブログについて</p>
<sectionclassName="mt-6 space-y-4 text-sm leading-7">
<p>
このブログは、日々の気づきや学びを、気軽に残していくための雑記ブログです。
Next.js(App Router)+ Markdown で記事を管理し、Vercelで公開する構成で作っています。
</p>
<h2className="mt-6 text-base font-semibold">運用方針</h2>
<ulclassName="list-disc pl-5">
<li>まずは継続を優先(短くてもOK)</li>
<li>記事は Markdown で管理して、増やしやすくする</li>
<li>後から整理できるように、カテゴリ・タグを付ける</li>
</ul>
<h2className="mt-6 text-base font-semibold">免責</h2>
<p>
記事の内容は可能な限り正確を心がけていますが、正確性・完全性を保証するものではありません。
ご利用は自己責任でお願いいたします。
</p>
</section>
<divclassName="mt-10 flex gap-4">
<Linkhref="/"className="text-sm underline">← トップへ戻る</Link>
<Linkhref="/privacy"className="text-sm underline">プライバシーポリシー →</Link>
</div>
</main>
);
}
✏️/contact
src/app/contact/page.tsx
// src/app/contact/page.tsx
importLinkfrom"next/link";
exportconst metadata = {
title:"Contact",
description:"お問い合わせ",
};
exportdefaultfunctionContactPage() {
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">Contact</h1>
<pclassName="mt-2 text-sm text-gray-600">お問い合わせ</p>
<sectionclassName="mt-6 space-y-4 text-sm leading-7">
<p>
お問い合わせは、以下の方法でお願いいたします。
(※現時点では簡易運用です。必要に応じてフォームを追加します)
</p>
<h2className="mt-6 text-base font-semibold">連絡方法</h2>
<ulclassName="list-disc pl-5">
<li>
メール:<spanclassName="font-mono">your-email@example.com</span>
(←ここは後であなたのメールに差し替え)
</li>
<li>内容:記事の誤り指摘・依頼・その他</li>
</ul>
<h2className="mt-6 text-base font-semibold">お願い</h2>
<ulclassName="list-disc pl-5">
<li>返信にはお時間をいただく場合があります</li>
<li>営業・勧誘目的のご連絡はご遠慮ください</li>
</ul>
</section>
<divclassName="mt-10 flex gap-4">
<Linkhref="/"className="text-sm underline">← トップへ戻る</Link>
<Linkhref="/about"className="text-sm underline">About →</Link>
</div>
</main>
);
}
✏️/privacy
src/app/privacy/page.tsx
// src/app/privacy/page.tsx
importLinkfrom"next/link";
exportconst metadata = {
title:"Privacy Policy",
description:"プライバシーポリシー",
};
exportdefaultfunctionPrivacyPage() {
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">プライバシーポリシー</h1>
<pclassName="mt-2 text-sm text-gray-600">当サイトにおける個人情報の取り扱いについて</p>
<sectionclassName="mt-6 space-y-6 text-sm leading-7">
<div>
<h2className="text-base font-semibold">1. 取得する情報</h2>
<p>
当サイトでは、お問い合わせ等の際に、氏名(またはハンドルネーム)・メールアドレス等の情報を取得する場合があります。
</p>
</div>
<div>
<h2className="text-base font-semibold">2. 利用目的</h2>
<ulclassName="list-disc pl-5">
<li>お問い合わせへの対応</li>
<li>不正行為の防止</li>
<li>サービス改善の参考</li>
</ul>
</div>
<div>
<h2className="text-base font-semibold">3. アクセス解析ツール</h2>
<p>
当サイトではアクセス解析ツールを利用する場合があります。取得される情報は匿名で収集され、個人を特定するものではありません。
</p>
</div>
<div>
<h2className="text-base font-semibold">4. 広告について(利用する場合)</h2>
<p>
当サイトで広告配信サービスを利用する場合、Cookie等によりユーザーの興味に基づいた広告が表示されることがあります。
</p>
</div>
<div>
<h2className="text-base font-semibold">5. 免責事項</h2>
<p>
当サイトの掲載内容によって生じた損害等について、一切の責任を負いかねます。ご了承ください。
</p>
</div>
<div>
<h2className="text-base font-semibold">6. 改定</h2>
<p>
本ポリシーは必要に応じて改定することがあります。最新の内容は本ページにてご確認ください。
</p>
</div>
</section>
<divclassName="mt-10 flex gap-4">
<Linkhref="/"className="text-sm underline">← トップへ戻る</Link>
<Linkhref="/contact"className="text-sm underline">Contact →</Link>
</div>
</main>
);
}
6-9. Step4 完了チェック
/categoriesが表示できる/categories/開発(例)が表示できる/tagsが表示できる/tags/Next.js(例)が表示できる/searchが動く/about/contact/privacyが表示できる- ヘッダーリンクで回遊できる
7. SEO最低ラインを整える
ゴール
- title/description が全ページで出る
- OGP(SNS共有)が成立する
/sitemap.xmlと/robots.txtが出る/rss.xml(任意)が出る- 404 / エラー時のページが出る
7-1. SITE_URL を決める(最重要)
本番URL(または独自ドメイン)を1つ決めます。
例:https://your-site.vercel.app
後で環境変数化します(Step6で実施)。
7-2. layout.tsx の metadata を強化(置き換え)
※ SITE_URL だけ差し替えます。
// src/app/layout.tsx
import"./globals.css";
importLinkfrom"next/link";
constSITE_URL ="<https://your-site.vercel.app>";// ←ここだけ変更
exportconst metadata = {
metadataBase:newURL(SITE_URL),
title: {
default:"雑記ブログ",
template:"%s | 雑記ブログ",
},
description:"Next.js + Markdown で作る雑記ブログ",
openGraph: {
title:"雑記ブログ",
description:"Next.js + Markdown で作る雑記ブログ",
url:SITE_URL,
siteName:"雑記ブログ",
locale:"ja_JP",
type:"website",
images: [{url:"/ogp.png",width:1200,height:630,alt:"雑記ブログ" }],
},
twitter: {
card:"summary_large_image",
images: ["/ogp.png"],
},
};
exportdefaultfunctionRootLayout({ children }: { children: React.ReactNode }) {
return (
<htmllang="ja">
<bodyclassName="min-h-screen bg-white text-gray-900">
<headerclassName="border-b">
<divclassName="mx-auto max-w-5xl px-4 py-4">
<divclassName="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Linkhref="/"className="text-xl font-bold">雑記ブログ</Link>
<navclassName="flex flex-wrap gap-4 text-sm">
<Linkhref="/"className="hover:underline">Home</Link>
<Linkhref="/categories"className="hover:underline">Categories</Link>
<Linkhref="/tags"className="hover:underline">Tags</Link>
<Linkhref="/search"className="hover:underline">Search</Link>
<Linkhref="/about"className="hover:underline">About</Link>
<Linkhref="/contact"className="hover:underline">Contact</Link>
<Linkhref="/privacy"className="hover:underline">Privacy</Link>
</nav>
</div>
</div>
</header>
<mainclassName="mx-auto max-w-5xl px-4 py-8">{children}</main>
<footerclassName="border-t">
<divclassName="mx-auto max-w-5xl px-4 py-6 text-sm text-gray-600">
<p>© {new Date().getFullYear()} 雑記ブログ</p>
</div>
</footer>
</body>
</html>
);
}
7-3. 記事ページに generateMetadata を追加
src/app/posts/[slug]/page.tsx を置き換えます(SITE_URLだけ変更)。
import { getPost }from"@/lib/posts";
constSITE_URL ="<https://your-site.vercel.app>";// ←ここだけ変更
typePageProps = {
params: {slug:string };
};
exportasyncfunctiongenerateMetadata({ params }: PageProps) {
const post =awaitgetPost(params.slug);
const title = post.title ||"記事";
const description = post.description ||"記事詳細ページです。";
const url =`${SITE_URL}/posts/${post.slug}`;
return {
title,
description,
openGraph: {
title,
description,
type:"article",
url,
images: [{url:"/ogp.png",width:1200,height:630,alt: title }],
},
twitter: {
card:"summary_large_image",
images: ["/ogp.png"],
},
};
}
exportdefaultasyncfunctionPostPage({ params }: PageProps) {
const post =awaitgetPost(params.slug);
return (
<mainclassName="mx-auto max-w-3xl px-4 py-10">
<h1className="text-2xl font-bold">{post.title}</h1>
<divclassName="mt-2 text-xs text-gray-600">{post.date}</div>
<article
className="prose prose-neutral mt-8 max-w-none"
dangerouslySetInnerHTML={{__html:post.contentHtml }}
/>
</main>
);
}
7-4. OGP画像を置く
public/ogp.png- 推奨:1200×630(仮でOK、後で差し替え)
7-5. sitemap.xml を出す(src/app/sitemap.ts)
importtype {MetadataRoute }from"next";
import { getAllPosts, getAllCategories, getAllTags }from"@/lib/posts";
exportdefaultfunctionsitemap():MetadataRoute.Sitemap {
const posts =getAllPosts();
const categories =getAllCategories();
const tags =getAllTags();
constbase:MetadataRoute.Sitemap = [
{url:"/",priority:1.0 },
{url:"/categories",priority:0.7 },
{url:"/tags",priority:0.7 },
{url:"/search",priority:0.5 },
{url:"/about",priority:0.3 },
{url:"/contact",priority:0.3 },
{url:"/privacy",priority:0.3 },
].map((x) => ({ ...x,lastModified:newDate() }));
constpostUrls:MetadataRoute.Sitemap = posts.map((p) => ({
url:`/posts/${p.slug}`,
priority:0.8,
lastModified:newDate(),
}));
constcategoryUrls:MetadataRoute.Sitemap = categories.map((c) => ({
url:`/categories/${encodeURIComponent(c.category)}`,
priority:0.6,
lastModified:newDate(),
}));
consttagUrls:MetadataRoute.Sitemap = tags.map((t) => ({
url:`/tags/${encodeURIComponent(t.tag)}`,
priority:0.6,
lastModified:newDate(),
}));
return [...base, ...postUrls, ...categoryUrls, ...tagUrls];
}
確認:http://localhost:3000/sitemap.xml
7-6. robots.txt を出す(src/app/robots.ts)
importtype {MetadataRoute }from"next";
exportdefaultfunctionrobots():MetadataRoute.Robots {
return {
rules: [{userAgent:"*",allow:"/" }],
sitemap:"/sitemap.xml",
};
}
確認:http://localhost:3000/robots.txt
7-7. RSS(任意)を出す(src/app/rss.xml/route.ts)
import { getAllPosts }from"@/lib/posts";
constSITE_URL ="<https://your-site.vercel.app>";// ←ここだけ変更
exportfunctionGET() {
const posts =getAllPosts();
const items = posts
.slice(0,30)
.map((p) => {
const link =`${SITE_URL}/posts/${p.slug}`;
const title =escapeXml(p.title ??"");
const desc =escapeXml(p.description ??"");
const pubDate = p.date ?newDate(p.date).toUTCString() :newDate().toUTCString();
return `
<item>
<title>${title}</title>
<link>${link}</link>
<guid>${link}</guid>
<description>${desc}</description>
<pubDate>${pubDate}</pubDate>
</item>`.trim();
})
.join("\\n");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>雑記ブログ</title>
<link>${SITE_URL}</link>
<description>Next.js + Markdown で作る雑記ブログ</description>
${items}
</channel>
</rss>`;
returnnewResponse(xml, {
headers: {"Content-Type":"application/xml; charset=utf-8" },
});
}
functionescapeXml(s:string) {
return s
.replaceAll("&","&")
.replaceAll("<","<")
.replaceAll(">",">")
.replaceAll('"',""")
.replaceAll("'","'");
}
確認:http://localhost:3000/rss.xml
7-8. 404 / 500 相当のエラーページを用意する
404:src/app/not-found.tsx
// src/app/not-found.tsx
importLinkfrom"next/link";
exportconst metadata = {
title:"ページが見つかりません",
description:"お探しのページは見つかりませんでした。",
};
exportdefaultfunctionNotFound() {
return (
<mainclassName="mx-auto max-w-3xl px-4 py-16">
<h1className="text-2xl font-bold">404 - ページが見つかりません</h1>
<pclassName="mt-4 text-sm text-gray-700">
URLが間違っているか、ページが移動した可能性があります。
</p>
<divclassName="mt-8">
<Linkhref="/"className="text-sm underline">
トップへ戻る
</Link>
</div>
</main>
);
}
500相当:src/app/error.tsx(クライアント必須)
// src/app/error.tsx
"use client";
importLinkfrom"next/link";
import { useEffect }from"react";
exportdefaultfunctionError({
error,
reset,
}: {
error:Error;
reset: () =>void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<mainclassName="mx-auto max-w-3xl px-4 py-16">
<h1className="text-2xl font-bold">エラーが発生しました</h1>
<pclassName="mt-4 text-sm text-gray-700">
申し訳ありません。時間をおいて再度お試しください。
</p>
<divclassName="mt-8 flex gap-4">
<button
onClick={() => reset()}
className="rounded border px-4 py-2 text-sm hover:bg-gray-50"
>
再試行
</button>
<Linkhref="/"className="text-sm underline">
トップへ戻る
</Link>
</div>
</main>
);
}
7-9. 最終チェック
/:タイトルが反映/posts/first-post:記事タイトル/descriptionが反映/sitemap.xml:表示される/robots.txt:表示される/rss.xml:表示される(作った場合)- 存在しないURL(例
/aaaa):404ページ
8. Vercelで本番公開する
ゴール
- GitHubにpush → Vercelが自動デプロイ
- 本番URLで
//posts/first-post/sitemap.xml/robots.txt/rss.xml(任意)が確認できる
8-1. GitHubにリポジトリ作成
- New repository →
my-blog - Public/PrivateはどちらでもOK
8-2. ローカルをGit管理してpush
git init
git add .
git commit -m"Initial commit"
git branch -M main
git remote add origin <https://github.com/あなたのID/my-blog.git>
git push -u origin main
8-3. VercelでImportしてデプロイ
- Vercel → Add New → Project
- GitHub連携 → リポジトリ選択 → Import → Deploy
8-4. 本番確認
https://.../https://.../posts/first-posthttps://.../sitemap.xmlhttps://.../robots.txthttps://.../rss.xml(作った場合)
8-5. よくある詰まり(本番だけ404)
原因:content/posts がGitに入ってない
対処:
git add content/posts
git commit -m"Add posts content"
git push
8-6. SITE_URLを環境変数にする(おすすめ)
ローカル:/.env.local
NEXT_PUBLIC_SITE_URL=http://localhost:3000
コード側(layout/posts/rss など)は:
constSITE_URL = process.env.NEXT_PUBLIC_SITE_URL ??"<http://localhost:3000>";
Vercel側:
- Settings → Environment Variables
- Key:
NEXT_PUBLIC_SITE_URL - Value:本番URL(例
https://my-blog-xxx.vercel.app) - Production(必要ならPreviewも)
9. 投稿フローを固定する(運用)
ゴール
- 考えずに記事を出せる
- 投稿回数が落ちない
9-1. 固定フロー(これ以外は増やさない)
- AIに投げる(タイトル・ファイル名・description)
- Markdownファイル作成
- frontmatter貼り付け
- 見出しテンプレ貼り付け
- 本文をAIに書かせる
- 最小修正(5分)
draft: false→ commit → push- Vercelで自動公開
9-2. AIに投げる固定プロンプト
以下のテーマでブログ記事を作成したいです。
・対象:Windowsユーザーの初心者
・技術:Next.js / Markdown
・目的:雑記ブログを継続運用するためのノウハウ共有
次の3点を提案してください。
① 記事タイトル(日本語)
② Markdownファイル名(YYYY-MM-DD-英数字.md)
③ description(120文字以内)
条件:
・ファイル名は英小文字+ハイフンのみ
・短く、意味が分かるもの
9-3. frontmatterテンプレ(固定)
---
title: "(AIが出したタイトル)"
date: "2025-12-28"
description: "(AIが出したdescription)"
category: "ブログ構築"
tags:
- ブログ運用
- Markdown
- Next.js
draft: true
---
9-4. 見出しテンプレ(固定)
## この記事で分かること
## なぜこのテーマが必要なのか
## 手順1:
## 手順2:
## 手順3:
## まとめ
9-5. 公開手順(固定)
draft: falseにする- pushする
git add content/posts/2025-12-28-xxx.md
git commit -m"add: new blog post"
git push
10. 詰まりポイント総まとめ
よくある詰まりと即復旧
- create-next-app コマンドのスペース抜け
npx create-next-app@latest my-blogが正しい globals.cssが無いsrc/app/globals.cssに作る@/lib/...が解決できないtsconfig.jsonのpathsを確認appとsrc/appが両方あるsrc/appに統一(不要な方を削除)- 変な挙動が続く
.nextを削除して再起動 - 本番だけ404(postsが出ない)
content/postsがGitHubに入っているか確認
付録:Vercelとは何か?個人ブログ運用の現実目線で解説
Vercelとは
Vercel(バーセル) は、
GitHubなどのリポジトリにコードを push するだけで、
- ビルド
- デプロイ
- 公開
までを 自動でやってくれるホスティングサービスです。
特に Next.jsとの相性が非常に良い ことで知られており、
個人ブログや小規模サイトであれば ほぼ設定不要・無料で運用できます。
Vercelで何ができるのか(超要点)
Vercelがやってくれることを一言でまとめると:
「GitHubにpushしたら、勝手にWebサイトが更新される」
具体的には次の流れです。
- ローカルで記事やコードを修正
- GitHub に push
- Vercel が自動で
- ビルド
- デプロイ
- 公開
- 数十秒後に本番サイトが更新される
👉 FTPアップロード・サーバー操作は一切不要です。
なぜNext.jsと相性が良いのか
Vercelは、もともと Next.jsを開発している会社が運営しています。
そのため:
- App Router
- SSG(静的生成)
- sitemap / robots / metadata
- Image / Font 最適化
といった Next.jsの標準機能が、そのまま最適な形で動く よう設計されています。
今回の構成:
- Markdown
- 静的生成(SSG)
- App Router
は、
Vercelの「一番得意な使い方」ど真ん中です。
無料プラン(Hobby)で何ができる?
※ 2025年時点・個人ブログ目線
① サイト公開・自動デプロイ
- 無制限にデプロイ可能
- GitHub連携
xxx.vercel.appの本番URLが使える- 独自ドメインも設定可能
👉 個人ブログでは 制限を感じることはほぼありません
② ビルド回数・デプロイ回数
- 記事追加
- 記事修正
- デザイン調整
👉 この程度なら 無料枠で余裕です。
「1日に何十回も push」
「CIを乱用」
しない限り問題になりません。
③ トラフィック(アクセス数)
結論から言うと:
👉 個人ブログは気にしなくてOK
目安として:
- 月 数万〜十数万PV → 問題なし
- SNSで一時的にバズる → ほぼ問題なし
⚠️ 常時大量アクセス
⚠️ APIを叩きまくる構成
でなければ、制限に引っかかることはありません。
無料プランで注意するポイント(知っておけば十分)
注意① Serverless / Edge Functions の多用
- API Route を大量に使う
- 動的処理が多い
- 外部APIを頻繁に呼ぶ
こうなると 実行回数制限が意識されます。
👉 今回のMarkdownブログ構成では一切関係ありません
注意② 画像最適化の使いすぎ
- 高解像度画像を大量に使う
- アクセスが非常に多い
場合は制限に近づくことがあります。
👉 回避策
- 画像は最小限
- 初期は気にしなくてOK
注意③ チーム・商用利用
- 複数人で管理
- 商用案件
- クライアントワーク
- SaaS開発
これらは Proプラン対象です。
👉 個人ブログ運用では関係なし
無料 → 有料を検討するのはいつ?
はっきり言います。
| 状況 | 有料必要? |
|---|---|
| 個人ブログ | ❌ 不要 |
| 副業ブログ(月数十万PV) | ほぼ不要 |
| API多用Webアプリ | ⭕ 検討 |
| チーム開発 | ⭕ 必須 |
| 商用SaaS | ⭕ 必須 |
👉 今のあなたの使い方では、有料を検討する必要はありません
Vercelを使う最大のメリット(本質)
Vercelの最大の価値は、
**「運用コストを限りなくゼロにできること」**です。
- サーバー管理しない
- デプロイ手順を考えない
- トラブル対応が激減
- 記事を書くことに集中できる
👉 ブログ運営に必要な「雑音」をすべて消してくれるのがVercelです。
まとめ
- Vercelは「pushしたら公開される」サービス
- Next.js × Markdown 構成と相性抜群
- 個人ブログ用途なら無料で十分
- 制限を気にする必要はほぼない
今回構築したブログ構成は、
Vercel無料プランのベストプラクティスです。

コメント