「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 の後ろにスペースが必要です。
※ my-blogの上の階層に移動していることが前提です。
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-0. 詰まりやすいポイント(先に潰す)
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
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. 必要ライブラリを入れる
※my-blog の直下で実行します。
npm install gray-matter remark remark-html
5-5. Markdown読込ロジックを作る(src/lib/posts.ts)
src/lib/posts.ts を作成して丸ごとコピペしてください。
// src/lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import { remark } from "remark";
import html from "remark-html";
const postsDirectory = path.join(process.cwd(), "content/posts");
/** frontmatter の型(Markdown側の標準) */
export type PostFrontMatter = {
title: string;
date: string; // "YYYY-MM-DD" 推奨
description?: string;
category?: string; // Step4で使用(今はなくてもOK)
tags?: string[]; // Step4で使用(今はなくてもOK)
};
/** 一覧用(Markdown本文は含めない) */
export type PostSummary = PostFrontMatter & {
slug: string;
};
/** 詳細用 */
export type PostDetail = PostFrontMatter & {
slug: string;
contentHtml: string;
};
function getAllSlugs(): string[] {
if (!fs.existsSync(postsDirectory)) return [];
const fileNames = fs.readdirSync(postsDirectory);
return fileNames
.filter((name) => name.endsWith(".md"))
.map((fileName) => fileName.replace(/\.md$/, ""));
}
function readPostFile(
slug: string
): { data: PostFrontMatter; content: string } | null {
const fullPath = path.join(postsDirectory, `${slug}.md`);
if (!fs.existsSync(fullPath)) return null;
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(fileContents);
const rawTags = (data as any).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)
: [];
const fm: PostFrontMatter = {
title: String((data as any).title ?? ""),
date: String((data as any).date ?? ""),
description: (data as any).description
? String((data as any).description)
: undefined,
category: (data as any).category
? String((data as any).category)
: undefined,
tags: normalizedTags,
};
return { data: fm, content };
}
/** 全記事(date降順) */
export function getAllPosts(): PostSummary[] {
const slugs = getAllSlugs();
return slugs
.map((slug) => {
const parsed = readPostFile(slug);
if (!parsed) return null;
return {
slug,
...parsed.data,
} satisfies PostSummary;
})
.filter((p): p is PostSummary => p !== null)
.sort((a, b) => (a.date < b.date ? 1 : -1));
}
/** 単記事(HTML変換込み) */
export async function getPost(slug: string): Promise<PostDetail> {
const parsed = readPostFile(slug);
if (!parsed) {
return {
slug,
title: "記事が見つかりません",
date: "",
description: "",
category: "",
tags: [],
contentHtml: "<p>Markdown ファイルが存在しません。</p>",
};
}
const processed = await remark().use(html).process(parsed.content);
const contentHtml = processed.toString();
return {
slug,
contentHtml,
...parsed.data,
};
}
5-6. トップページ(記事一覧)を作る(src/app/page.tsx)
src/app/page.tsx を丸ごと置き換えます。
※ 初期値の「app」フォルダは「old_app」など名前を変更します。
// src/app/page.tsx
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";
export default function HomePage() {
const posts = getAllPosts();
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">雑記ブログ</h1>
<p className="mt-2 text-sm text-gray-600">
Next.js(App Router)+ Markdown で作る雑記ブログです。
</p>
{posts.length === 0 ? (
<div className="mt-6 rounded border p-4">
<p className="text-sm">
まだ記事がありません。
<code className="px-1">content/posts</code> に Markdown を追加してください。
</p>
</div>
) : (
<ul className="mt-6 space-y-4">
{posts.map((p) => (
<li key={p.slug} className="rounded border p-4 hover:bg-gray-50">
<Link href={`/posts/${p.slug}`}>
<h2 className="text-lg font-semibold">{p.title}</h2>
</Link>
<div className="mt-1 text-xs text-gray-600">{p.date}</div>
{p.description ? (
<p className="mt-2 text-sm text-gray-700">
{p.description}
</p>
) : null}
<div className="mt-3">
<Link
href={`/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
中身をコピペ:
import { getPost } from "@/lib/posts";
type PageProps = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: PageProps) {
const { slug } = await params; // ← ここが重要
const post = await getPost(slug);
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">{post.title}</h1>
<div className="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 */
@import "tailwindcss";
/* 読み込み確認用 */
html { background: #fff; }
5-9. layout.tsx を最低限正しくする(src/app/layout.tsx)
src/app/layout.tsx を丸ごと置き換えます。
// src/app/layout.tsx
import "./globals.css";
import Link from "next/link";
export const metadata = {
title: {
default: "雑記ブログ",
template: "%s | 雑記ブログ",
},
description: "Next.js + Markdown で作る雑記ブログ",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body className="min-h-screen bg-white text-gray-900">
<header className="border-b">
<div className="mx-auto max-w-5xl px-4 py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Link href="/" className="text-xl font-bold">
雑記ブログ
</Link>
<nav className="flex flex-wrap gap-4 text-sm">
<Link href="/" className="hover:underline">Home</Link>
<Link href="/categories" className="hover:underline">Categories</Link>
<Link href="/tags" className="hover:underline">Tags</Link>
<Link href="/search" className="hover:underline">Search</Link>
<Link href="/about" className="hover:underline">About</Link>
<Link href="/contact" className="hover:underline">Contact</Link>
<Link href="/privacy" className="hover:underline">Privacy</Link>
</nav>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8">
{children}
</main>
<footer className="border-t">
<div className="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 が表示される
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"] --- これは最初の記事です。 - AI を使ってブログを作っています - Next.js + Markdown 構成です - 雑記ブログとして気軽に書いていきます
6-2. posts.ts にカテゴリ/タグ/検索用関数を追加
src/lib/posts.ts の末尾に追記します。
/** カテゴリ別の記事一覧(date降順) */
export function getPostsByCategory(category: string) {
const decoded = decodeURIComponent(category);
return getAllPosts().filter(
(p) => (p.category ?? "") === decoded
);
}
/** タグ別の記事一覧(date降順) */
export function getPostsByTag(tag: string) {
const decoded = decodeURIComponent(tag);
return getAllPosts().filter(
(p) => (p.tags ?? []).includes(decoded)
);
}
/** カテゴリ一覧(件数付き・件数降順) */
export function getAllCategories(): { category: string; count: number }[] {
const map = new Map<string, number>();
for (const p of getAllPosts()) {
const c = (p.category ?? "").trim();
if (!c) continue;
map.set(c, (map.get(c) ?? 0) + 1);
}
return Array.from(map.entries())
.map(([category, count]) => ({ category, count }))
.sort(
(a, b) =>
b.count - a.count ||
a.category.localeCompare(b.category, "ja")
);
}
/** タグ一覧(件数付き・件数降順) */
export function getAllTags(): { tag: string; count: number }[] {
const map = new Map<string, number>();
for (const p of getAllPosts()) {
for (const t of p.tags ?? []) {
const tag = String(t).trim();
if (!tag) continue;
map.set(tag, (map.get(tag) ?? 0) + 1);
}
}
return Array.from(map.entries())
.map(([tag, count]) => ({ tag, count }))
.sort(
(a, b) =>
b.count - a.count ||
a.tag.localeCompare(b.tag, "ja")
);
}
/** 検索(title/description 部分一致・大文字小文字無視) */
export function searchPosts(query: string) {
const q = (query ?? "").trim().toLowerCase();
if (!q) return [];
return getAllPosts().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
import Link from "next/link";
import { getAllCategories } from "@/lib/posts";
export const metadata = {
title: "カテゴリ一覧",
description: "カテゴリ別に記事を一覧できます。",
};
export default function CategoriesPage() {
const categories = getAllCategories();
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">カテゴリ一覧</h1>
<p className="mt-2 text-sm text-gray-600">
カテゴリ別に記事を探せます。
</p>
{categories.length === 0 ? (
<div className="mt-6 rounded border p-4">
<p className="text-sm">
まだカテゴリがありません。frontmatter に{" "}
<code className="px-1">category</code> を追加してください。
</p>
</div>
) : (
<ul className="mt-6 space-y-3">
{categories.map(({ category, count }) => (
<li key={category}>
<Link
href={`/categories/${encodeURIComponent(category)}`}
className="flex items-center justify-between rounded border px-4 py-3 hover:bg-gray-50"
>
<span className="font-medium">{category}</span>
<span className="text-sm text-gray-600">{count}件</span>
</Link>
</li>
))}
</ul>
)}
<div className="mt-10">
<Link href="/" className="text-sm underline">
← トップへ戻る
</Link>
</div>
</main>
);
}
6-4. /categories/[category] カテゴリ別記事一覧
src/app/categories/[category]/page.tsx
import Link from "next/link";
import { getPostsByCategory, getAllCategories } from "@/lib/posts";
type PageProps = {
params: { category: string };
};
export async function generateStaticParams() {
return getAllCategories().map((c) => ({
category: encodeURIComponent(c.category),
}));
}
export default function CategoryPostsPage({ params }: PageProps) {
const category = decodeURIComponent(params.category);
const posts = getPostsByCategory(params.category);
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">カテゴリ:{category}</h1>
<p className="mt-2 text-sm text-gray-600">
このカテゴリの記事一覧です。
</p>
{posts.length === 0 ? (
<div className="mt-6 rounded border p-4">
<p className="text-sm">
このカテゴリの記事はまだありません。
</p>
</div>
) : (
<ul className="mt-6 space-y-4">
{posts.map((post) => (
<li
key={post.slug}
className="rounded border p-4 hover:bg-gray-50"
>
<Link href={`/posts/${post.slug}`}>
<h2 className="text-lg font-semibold">{post.title}</h2>
</Link>
<div className="mt-1 text-xs text-gray-600">
{post.date}
</div>
{post.description ? (
<p className="mt-2 text-sm text-gray-700">
{post.description}
</p>
) : null}
<div className="mt-3">
<Link
href={`/posts/${post.slug}`}
className="text-sm underline"
>
記事を読む →
</Link>
</div>
</li>
))}
</ul>
)}
<div className="mt-10 flex gap-4">
<Link href="/categories" className="text-sm underline">
← カテゴリ一覧へ
</Link>
<Link href="/" className="text-sm underline">
トップへ戻る
</Link>
</div>
</main>
);
}
6-5. /tags タグ一覧ページ
src/app/tags/page.tsx
import Link from "next/link";
import { getAllTags } from "@/lib/posts";
export const metadata = {
title: "タグ一覧",
description: "タグ別に記事を一覧できます。",
};
export default function TagsPage() {
const tags = getAllTags();
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">タグ一覧</h1>
<p className="mt-2 text-sm text-gray-600">
タグ別に記事を探せます。
</p>
{tags.length === 0 ? (
<div className="mt-6 rounded border p-4">
<p className="text-sm">
まだタグがありません。frontmatter に{" "}
<code className="px-1">tags</code> を追加してください。
</p>
</div>
) : (
<ul className="mt-6 space-y-3">
{tags.map(({ tag, count }) => (
<li key={tag}>
<Link
href={`/tags/${encodeURIComponent(tag)}`}
className="flex items-center justify-between rounded border px-4 py-3 hover:bg-gray-50"
>
<span className="font-medium">{tag}</span>
<span className="text-sm text-gray-600">{count}件</span>
</Link>
</li>
))}
</ul>
)}
<div className="mt-10 flex gap-4">
<Link href="/" className="text-sm underline">
← トップへ戻る
</Link>
<Link href="/categories" className="text-sm underline">
カテゴリ一覧へ →
</Link>
</div>
</main>
);
}
6-6. /tags/[tag] タグ別記事一覧
src/app/tags/[tag]/page.tsx
import Link from "next/link";
import { getAllTags, getPostsByTag } from "@/lib/posts";
type PageProps = {
params: { tag: string };
};
export async function generateStaticParams() {
return getAllTags().map((t) => ({
tag: encodeURIComponent(t.tag),
}));
}
export default function TagPostsPage({ params }: PageProps) {
const tag = decodeURIComponent(params.tag);
const posts = getPostsByTag(params.tag);
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">タグ:{tag}</h1>
<p className="mt-2 text-sm text-gray-600">
このタグの記事一覧です。
</p>
{posts.length === 0 ? (
<div className="mt-6 rounded border p-4">
<p className="text-sm">
このタグの記事はまだありません。
</p>
</div>
) : (
<ul className="mt-6 space-y-4">
{posts.map((post) => (
<li
key={post.slug}
className="rounded border p-4 hover:bg-gray-50"
>
<Link href={`/posts/${post.slug}`}>
<h2 className="text-lg font-semibold">{post.title}</h2>
</Link>
<div className="mt-1 text-xs text-gray-600">
{post.date}
</div>
{post.description ? (
<p className="mt-2 text-sm text-gray-700">
{post.description}
</p>
) : null}
<div className="mt-3 flex flex-wrap gap-3">
<Link
href={`/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 className="mt-10 flex gap-4">
<Link href="/tags" className="text-sm underline">
← タグ一覧へ
</Link>
<Link href="/" className="text-sm underline">
トップへ戻る
</Link>
</div>
</main>
);
}
6-7. /search?q=xxx 検索ページ
src/app/search/page.tsx
import Link from "next/link";
import { searchPosts } from "@/lib/posts";
export const metadata = {
title: "検索",
description: "記事タイトル・説明文から検索できます。",
};
type PageProps = {
searchParams?: { q?: string };
};
export default function SearchPage({ searchParams }: PageProps) {
const q = (searchParams?.q ?? "").trim();
const results = q ? searchPosts(q) : [];
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">検索</h1>
<p className="mt-2 text-sm text-gray-600">
タイトル / 説明文(description)から簡易検索できます。
</p>
<form method="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"
/>
<button
type="submit"
className="rounded border px-4 py-2 text-sm hover:bg-gray-50"
>
検索
</button>
</form>
{q === "" ? (
<div className="mt-6 rounded border p-4">
<p className="text-sm text-gray-700">
キーワードを入力して検索してください。
</p>
</div>
) : results.length === 0 ? (
<div className="mt-6 rounded border p-4">
<p className="text-sm text-gray-700">
「<span className="font-semibold">{q}</span>
」に一致する記事は見つかりませんでした。
</p>
</div>
) : (
<div className="mt-6">
<p className="text-sm text-gray-600">
「<span className="font-semibold">{q}</span>
」の検索結果:{results.length}件
</p>
<ul className="mt-4 space-y-4">
{results.map((post) => (
<li
key={post.slug}
className="rounded border p-4 hover:bg-gray-50"
>
<Link href={`/posts/${post.slug}`}>
<h2 className="text-lg font-semibold">{post.title}</h2>
</Link>
<div className="mt-1 text-xs text-gray-600">
{post.date}
</div>
{post.description ? (
<p className="mt-2 text-sm text-gray-700">
{post.description}
</p>
) : null}
<div className="mt-3 flex gap-3">
<Link
href={`/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>
)}
<div className="mt-10 flex gap-4">
<Link href="/" className="text-sm underline">
← トップへ戻る
</Link>
<Link href="/categories" className="text-sm underline">
カテゴリ一覧へ →
</Link>
</div>
</main>
);
}
6-8. 固定ページ(About / Contact / Privacy)
✏️/about
src/app/about/page.tsx
// src/app/about/page.tsx
import Link from "next/link";
export const metadata = {
title: "About",
description: "このブログについて",
};
export default function AboutPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">About</h1>
<p className="mt-2 text-sm text-gray-600">
このブログについて
</p>
<section className="mt-6 space-y-4 text-sm leading-7">
<p>
このブログは、日々の気づきや学びを、気軽に残していくための雑記ブログです。
Next.js(App Router)+ Markdown で記事を管理し、Vercelで公開する構成で作っています。
</p>
<h2 className="mt-6 text-base font-semibold">運用方針</h2>
<ul className="list-disc pl-5">
<li>まずは継続を優先(短くてもOK)</li>
<li>記事は Markdown で管理して、増やしやすくする</li>
<li>後から整理できるように、カテゴリ・タグを付ける</li>
</ul>
<h2 className="mt-6 text-base font-semibold">免責</h2>
<p>
記事の内容は可能な限り正確を心がけていますが、正確性・完全性を保証するものではありません。
ご利用は自己責任でお願いいたします。
</p>
</section>
<div className="mt-10 flex gap-4">
<Link href="/" className="text-sm underline">
← トップへ戻る
</Link>
<Link href="/privacy" className="text-sm underline">
プライバシーポリシー →
</Link>
</div>
</main>
);
}
✏️/contact
src/app/contact/page.tsx
// src/app/contact/page.tsx
import Link from "next/link";
export const metadata = {
title: "Contact",
description: "お問い合わせ",
};
export default function ContactPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">Contact</h1>
<p className="mt-2 text-sm text-gray-600">
お問い合わせ
</p>
<section className="mt-6 space-y-4 text-sm leading-7">
<p>
お問い合わせは、以下の方法でお願いいたします。
(※現時点では簡易運用です。必要に応じてフォームを追加します)
</p>
<h2 className="mt-6 text-base font-semibold">連絡方法</h2>
<ul className="list-disc pl-5">
<li>
メール:
<span className="font-mono">your-email@example.com</span>
(←ここは後であなたのメールに差し替え)
</li>
<li>内容:記事の誤り指摘・依頼・その他</li>
</ul>
<h2 className="mt-6 text-base font-semibold">お願い</h2>
<ul className="list-disc pl-5">
<li>返信にはお時間をいただく場合があります</li>
<li>営業・勧誘目的のご連絡はご遠慮ください</li>
</ul>
</section>
<div className="mt-10 flex gap-4">
<Link href="/" className="text-sm underline">
← トップへ戻る
</Link>
<Link href="/about" className="text-sm underline">
About →
</Link>
</div>
</main>
);
}
✏️/privacy
src/app/privacy/page.tsx
// src/app/privacy/page.tsx
import Link from "next/link";
export const metadata = {
title: "Privacy Policy",
description: "プライバシーポリシー",
};
export default function PrivacyPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">プライバシーポリシー</h1>
<p className="mt-2 text-sm text-gray-600">
当サイトにおける個人情報の取り扱いについて
</p>
<section className="mt-6 space-y-6 text-sm leading-7">
<div>
<h2 className="text-base font-semibold">1. 取得する情報</h2>
<p>
当サイトでは、お問い合わせ等の際に、氏名(またはハンドルネーム)・
メールアドレス等の情報を取得する場合があります。
</p>
</div>
<div>
<h2 className="text-base font-semibold">2. 利用目的</h2>
<ul className="list-disc pl-5">
<li>お問い合わせへの対応</li>
<li>不正行為の防止</li>
<li>サービス改善の参考</li>
</ul>
</div>
<div>
<h2 className="text-base font-semibold">3. アクセス解析ツール</h2>
<p>
当サイトではアクセス解析ツールを利用する場合があります。
取得される情報は匿名で収集され、個人を特定するものではありません。
</p>
</div>
<div>
<h2 className="text-base font-semibold">
4. 広告について(利用する場合)
</h2>
<p>
当サイトで広告配信サービスを利用する場合、Cookie等により
ユーザーの興味に基づいた広告が表示されることがあります。
</p>
</div>
<div>
<h2 className="text-base font-semibold">5. 免責事項</h2>
<p>
当サイトの掲載内容によって生じた損害等について、
一切の責任を負いかねます。ご了承ください。
</p>
</div>
<div>
<h2 className="text-base font-semibold">6. 改定</h2>
<p>
本ポリシーは必要に応じて改定することがあります。
最新の内容は本ページにてご確認ください。
</p>
</div>
</section>
<div className="mt-10 flex gap-4">
<Link href="/" className="text-sm underline">
← トップへ戻る
</Link>
<Link href="/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";
import Link from "next/link";
const SITE_URL = "https://your-site.vercel.app"; // ←ここだけ変更
export const metadata = {
metadataBase: new URL(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"],
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body className="min-h-screen bg-white text-gray-900">
<header className="border-b">
<div className="mx-auto max-w-5xl px-4 py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<Link href="/" className="text-xl font-bold">
雑記ブログ
</Link>
<nav className="flex flex-wrap gap-4 text-sm">
<Link href="/" className="hover:underline">Home</Link>
<Link href="/categories" className="hover:underline">Categories</Link>
<Link href="/tags" className="hover:underline">Tags</Link>
<Link href="/search" className="hover:underline">Search</Link>
<Link href="/about" className="hover:underline">About</Link>
<Link href="/contact" className="hover:underline">Contact</Link>
<Link href="/privacy" className="hover:underline">Privacy</Link>
</nav>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-4 py-8">
{children}
</main>
<footer className="border-t">
<div className="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";
const SITE_URL = "https://your-site.vercel.app"; // ←ここだけ変更
type PageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(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"],
},
};
}
export default async function PostPage({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold">{post.title}</h1>
<div className="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)
import type { MetadataRoute } from "next";
import {
getAllPosts,
getAllCategories,
getAllTags,
} from "@/lib/posts";
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getAllPosts();
const categories = getAllCategories();
const tags = getAllTags();
const base: 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: new Date(),
}));
const postUrls: MetadataRoute.Sitemap = posts.map((p) => ({
url: `/posts/${p.slug}`,
priority: 0.8,
lastModified: new Date(),
}));
const categoryUrls: MetadataRoute.Sitemap = categories.map((c) => ({
url: `/categories/${encodeURIComponent(c.category)}`,
priority: 0.6,
lastModified: new Date(),
}));
const tagUrls: MetadataRoute.Sitemap = tags.map((t) => ({
url: `/tags/${encodeURIComponent(t.tag)}`,
priority: 0.6,
lastModified: new Date(),
}));
return [...base, ...postUrls, ...categoryUrls, ...tagUrls];
}
確認:http://localhost:3000/sitemap.xml
7-6. robots.txt を出す(src/app/robots.ts)
import type { MetadataRoute } from "next";
export default function robots(): 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";
const SITE_URL = "https://your-site.vercel.app"; // ←ここだけ変更
export function GET() {
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
? new Date(p.date).toUTCString()
: new Date().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>`;
return new Response(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
},
});
}
function escapeXml(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
import Link from "next/link";
export const metadata = {
title: "ページが見つかりません",
description: "お探しのページは見つかりませんでした。",
};
export default function NotFound() {
return (
<main className="mx-auto max-w-3xl px-4 py-16">
<h1 className="text-2xl font-bold">
404 - ページが見つかりません
</h1>
<p className="mt-4 text-sm text-gray-700">
URLが間違っているか、ページが移動した可能性があります。
</p>
<div className="mt-8">
<Link href="/" className="text-sm underline">
トップへ戻る
</Link>
</div>
</main>
);
}
500相当:src/app/error.tsx(クライアント必須)
// src/app/error.tsx
"use client";
import Link from "next/link";
import { useEffect } from "react";
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<main className="mx-auto max-w-3xl px-4 py-16">
<h1 className="text-2xl font-bold">
エラーが発生しました
</h1>
<p className="mt-4 text-sm text-gray-700">
申し訳ありません。時間をおいて再度お試しください。
</p>
<div className="mt-8 flex gap-4">
<button
onClick={() => reset()}
className="rounded border px-4 py-2 text-sm hover:bg-gray-50"
>
再試行
</button>
<Link href="/" className="text-sm underline">
トップへ戻る
</Link>
</div>
</main>
);
}
7-9. 最終チェック
/:タイトルが反映/posts/first-post:記事タイトル/descriptionが反映/sitemap.xml:表示される/robots.txt:表示される/rss.xml:表示される(作った場合)- 存在しないURL(例
/aaaa):404ページ
8. Vercelで本番公開する
この章では、作成したブログを Vercel 上で公開するまでの手順を説明します。
Vercel を使う最大のメリットは、GitHub に push するだけで自動公開される点です。
このステップのゴール
-
GitHub に
csharp-newsリポジトリを作成する -
ローカル(
D:\dev)のプロジェクトを GitHub に push する -
Vercel にインポートして公開 URL を取得する
① GitHub に csharp-news リポジトリを作成する
-
GitHub にログイン
-
右上の 「+」→「New repository」 をクリック
-
以下を入力
| 項目 | 設定値 |
|---|---|
| Repository name | my-blog |
| Description | 任意(空でOK) |
| Public / Private | Public |
| Initialize README | チェックしない |
| .gitignore | None |
| License | None |
-
Create repository をクリック
※ README を GitHub 側で作成しないのがポイントです
(ローカルから初回 push するため)
② ローカル作業フォルダを準備する
作業フォルダは D:\dev\blog を使用します。(手順どおり進めていればフォルダは作成されているはずです。)
-
エクスプローラーで
D:\dev\を作成blog\my-blog -
VS Code でそのフォルダを開く
③ Git リポジトリを初期化する
VS Code のターミナルで以下を実行します。
④ 初回 commit を作成する
⑥ GitHub リポジトリと紐づける
GitHub で作成した my-blog リポジトリの URL を使用します。
⑦ GitHub に push する
この時点で、GitHub 上に my-blog リポジトリが反映されます。
⑧ Vercel にインポートして公開する
-
Vercel にログイン
-
New Project をクリック
-
GitHub を連携
-
my-blogリポジトリを選択 -
設定は変更せず Deploy
数十秒後、以下のような URL が発行されます。
これで 公開完了です。
※SITE_URL のURLをかきかえます。
⑨ 以降の運用フロー
Vercel 公開後は、以下の流れだけで更新できます。
手動のアップロード作業やサーバー操作は一切不要です。
この章のポイントまとめ
-
公開作業は GitHub への push が起点
-
Vercel 側の設定はほぼ不要
-
記事追加・修正はすべて自動反映される
9. 投稿フローを固定する(運用)
ゴール
- 考えずに記事を出せる
- 投稿回数が落ちない
9-1. 固定フロー(これ以外は増やさない)
- AIに投げる(タイトル・ファイル名・description)
- Markdownファイル作成(content\posts\first-post.md)
- frontmatter貼り付け
- 見出しテンプレ貼り付け
- 本文をAIに書かせる
- 最小修正(5分)
draft: false→ commit → push- Vercelで自動公開
9-2. AIに投げる固定プロンプト
あなたは「Windowsユーザー向け技術ブログの編集者兼ライター」です。 【前提】 ・対象:Windowsユーザーの初心者 ・技術:Next.js / Markdown ・目的:雑記ブログを継続運用するためのノウハウ共有 ・ブログ構成・思想は https://hissori.com/ai-nextjs-blog-guide/ に準拠 【やってほしいこと】 以下の内容をすべて満たす「記事1本」を作成してください。 1. 記事タイトル(日本語) 2. Markdownファイル名(YYYY-MM-DD-英小文字+ハイフン.md) 3. description(120文字以内) 4. frontmatter(draft:true) 5. 記事本文(Markdown形式) 【構成ルール】 ・frontmatter は下記テンプレを必ず使用すること ・見出し構成も下記テンプレを必ず使用すること ・見出し2(##)直下には簡潔な説明文を書くこと ・Windows初心者でも理解できる言葉を使うこと ・技術的な思想論や抽象論は書かないこと ・「運用を続けるための考え方」を重視すること 【frontmatterテンプレ】 --- title: "" date: "YYYY-MM-DD" description: "" category: "ブログ構築" tags: - ブログ運用 - Markdown - Next.js draft: true --- 【見出しテンプレ】 ## この記事で分かること ## なぜこのテーマが必要なのか ## 手順1: ## 手順2: ## 手順3: ## まとめ 【出力条件】 ・1つのMarkdown記事として、最初から最後まで完成させること ・途中で質問せず、自己判断で仕上げること こ
※「前提」箇所を書き換えてブログの内容を変更してください。
9-3. 公開手順
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無料プランのベストプラクティスです。


コメント