AI × Next.js × Markdown で作る雑記ブログ構築ガイド

システム開発
スポンサーリンク
スポンサーリンク

「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. ターミナルを開く

  1. Windowsキー + X
  2. 「ターミナル(Windows Terminal)」を開く

表示例:

C:\\Users\\ユーザー名>

3-2. Node.js(LTS)をインストール

  1. ブラウザで https://nodejs.org/ を開く
  2. LTS を選択
  3. .msi をダウンロードして実行
  4. すべて Next で進める

確認(ターミナルで実行):

node -v
npm -v

数字が出ればOKです。

3-3. VS Code をインストール

  1. https://code.visualstudio.com/ を開く
  2. ダウンロード → インストール

推奨チェック:

  • 「PATH に追加」
  • 「右クリックで Code で開く」

3-4. 作業フォルダを作る

エクスプローラーで作成:

C:\\dev\\blog

※ 日本語パスは避けます。

3-5. VS Code でフォルダを開く

  1. VS Code を起動
  2. 「フォルダーを開く」
  3. 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. appsrc/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("&","&amp;")
    .replaceAll("<","&lt;")
    .replaceAll(">","&gt;")
    .replaceAll('"',"&quot;")
    .replaceAll("'","&apos;");
}

確認: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-post
  • https://.../sitemap.xml
  • https://.../robots.txt
  • https://.../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. 固定フロー(これ以外は増やさない)

  1. AIに投げる(タイトル・ファイル名・description)
  2. Markdownファイル作成
  3. frontmatter貼り付け
  4. 見出しテンプレ貼り付け
  5. 本文をAIに書かせる
  6. 最小修正(5分)
  7. draft: false → commit → push
  8. 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.jsonpaths を確認
  • appsrc/app が両方あるsrc/app に統一(不要な方を削除)
  • 変な挙動が続く.next を削除して再起動
  • 本番だけ404(postsが出ない)content/posts がGitHubに入っているか確認

付録:Vercelとは何か?個人ブログ運用の現実目線で解説

Vercelとは

Vercel(バーセル) は、

GitHubなどのリポジトリにコードを push するだけで、

  • ビルド
  • デプロイ
  • 公開

までを 自動でやってくれるホスティングサービスです。

特に Next.jsとの相性が非常に良い ことで知られており、

個人ブログや小規模サイトであれば ほぼ設定不要・無料で運用できます。

Vercelで何ができるのか(超要点)

Vercelがやってくれることを一言でまとめると:

「GitHubにpushしたら、勝手にWebサイトが更新される」

具体的には次の流れです。

  1. ローカルで記事やコードを修正
  2. GitHub に push
  3. Vercel が自動で
    • ビルド
    • デプロイ
    • 公開
  4. 数十秒後に本番サイトが更新される

👉 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無料プランのベストプラクティスです。

システム開発
スポンサーリンク
シェアする
tobotoboをフォローする

コメント

タイトルとURLをコピーしました