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 の後ろにスペースが必要です。
※ 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. appsrc/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("&", "&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
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 リポジトリを作成する

  1. GitHub にログイン

  2. 右上の 「+」→「New repository」 をクリック

  3. 以下を入力

項目 設定値
Repository name my-blog
Description 任意(空でOK)
Public / Private Public
Initialize README チェックしない
.gitignore None
License None
  1. Create repository をクリック

※ README を GitHub 側で作成しないのがポイントです
(ローカルから初回 push するため)

② ローカル作業フォルダを準備する

作業フォルダは D:\dev\blog を使用します。(手順どおり進めていればフォルダは作成されているはずです。)

D:\dev\blog\my-blog
  1. エクスプローラーで D:\dev\blog\my-blog を作成

  2. VS Code でそのフォルダを開く

③ Git リポジトリを初期化する

VS Code のターミナルで以下を実行します。

cd D:\dev\blog\my-blog git init

④ 初回 commit を作成する

git add .
git commit -m "initial commit"

⑥ GitHub リポジトリと紐づける

GitHub で作成した my-blog リポジトリの URL を使用します。

git branch -M main
git remote add origin https://github.com/ユーザー名/my-blog.git

⑦ GitHub に push する

# GitHub側にコミットがある場合
git pull origin main --allow-unrelated-histories
git push -u origin main

この時点で、GitHub 上に my-blog リポジトリが反映されます。

⑧ Vercel にインポートして公開する

  1. Vercel にログイン

  2. New Project をクリック

  3. GitHub を連携

  4. my-blog リポジトリを選択

  5. 設定は変更せず Deploy

数十秒後、以下のような URL が発行されます。

https://my-blog.vercel.app

これで 公開完了です。

※SITE_URL のURLをかきかえます。

⑨ 以降の運用フロー

Vercel 公開後は、以下の流れだけで更新できます。

Markdown を追加・修正

git commit

git push

自動で再デプロイ

手動のアップロード作業やサーバー操作は一切不要です。

この章のポイントまとめ

  • 公開作業は GitHub への push が起点

  • Vercel 側の設定はほぼ不要

  • 記事追加・修正はすべて自動反映される

9. 投稿フローを固定する(運用)

ゴール

  • 考えずに記事を出せる
  • 投稿回数が落ちない

9-1. 固定フロー(これ以外は増やさない)

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

補足:運用してうまくいかなかった点まとめ

Next.js + Markdownブログで「リンクがクリックできない」原因と解決
Next.js と Markdown を使ったブログ構成では、URL が表示されているのにクリックできないというトラブルに遭遇することがあります。一見すると CSS の問題に見えるため、原因の切り分けでハマりやすいのが特徴です。本記事では、...

付録: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をコピーしました