Next.js と Qiita API で作る技術ブログ

Qiita API を利用した技術ブログ制作に挑戦してみました。

リポジトリを公開しています。

https://github.com/tatsuyayamakawa/qiita-blog

## 技術スタック

- プログラミング言語: TypeScript
- ライブラリ: React
- フレームワーク: Next.js
- スタイリング: Tailwind CSS
- UI ライブラリ: Shadcn/ui

## Qiita API の概要

Qiita API は、データの種類に応じて以下の 2 つの API を提供しています。

- パブリック API
- プライベート API

API の詳細な仕様は以下のドキュメントで確認できます。
https://qiita.com/api/v2/docs

### パブリック API の機能

パブリック API では、以下のような公開データにアクセスできます。

- 記事の一覧取得（GET /api/v2/items）
- 特定の記事の取得（GET /api/v2/items/:item_id）
- ユーザー情報の取得（GET /api/v2/users/:user_id）
- タグ一覧の取得（GET /api/v2/tags）
- 特定のタグの記事一覧取得（GET /api/v2/tags/:tag_id/items）

### プライベート API の機能

認証が必要な以下の操作は、プライベート API でのみ利用可能です。

- 記事の投稿・更新・削除（POST/PATCH/DELETE /api/v2/items）
- 記事のストック・アンストック（PUT/DELETE /api/v2/items/:item_id/stock）
- 記事へのいいね（PUT/DELETE /api/v2/items/:item_id/like）
- ユーザー自身の情報取得（GET /api/v2/authenticated_user）
- ユーザー自身の記事一覧取得（GET /api/v2/authenticated_user/items）

### API 利用における注意点

今回の実装では、記事データの取得と表示が主な目的です。
これはパブリック API の範囲内で実現可能ですが、以下の点に注意が必要です。

**レート制限**

- 未認証リクエスト: 1 時間あたり 60 リクエスト
- 認証済みリクエスト: 1 時間あたり 1000 リクエスト

**ユーザー ID 変更への対応**

- パブリック API のみの利用では、ユーザー ID 変更時にコードの修正が必要
- アクセストークンを取得することで、より柔軟な運用が可能

以上の点から、しっかりアクセストークンを取得し利用する方法で進めていきます。

## 実装手順

### 1. プロジェクトのセットアップ

#### Next.js と Shadcn/ui のインストール

```:terminal
$ bun create next-app@latest
```

https://nextjs.org/docs/app/getting-started/installation

```:terminal
$ bunx --bun shadcn@latest add
```

https://ui.shadcn.com/docs/installation/next

#### 使用パッケージのバージョン

```json:package.json
{
  "dependencies": {
    "@radix-ui/react-slot": "^1.2.3",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "lucide-react": "^0.511.0",
    "next": "15.1.8",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "tailwind-merge": "^3.3.0",
    "tailwindcss-animate": "^1.0.7"
  },
  "devDependencies": {
    "typescript": "^5",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "eslint": "^9",
    "eslint-config-next": "15.1.8",
    "@eslint/eslintrc": "^3"
  }
}
```

### 2. UI コンポーネントの導入

#### 必要なコンポーネントのインストール

以下のコンポーネントをインストールします。

- Button: グローバルメニュー用
- Card: 記事一覧表示用

```:terminal
$ bunx --bun shadcn@latest add button
```

https://ui.shadcn.com/docs/components/button#installation

```:terminal
$ bunx --bun shadcn@latest add card
```

https://ui.shadcn.com/docs/components/card#installation

### 3. レイアウトの実装

#### ヘッダーコンポーネント

```react:src/components/layout/header.tsx
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { siteConfig } from "@/lib/config";

const { title } = siteConfig;

export function Header() {
  return (
    <header className="border-b">
      <div className="container mx-auto py-4 px-4 flex justify-between items-center">
        <div className="flex items-center gap-4">
          <Link href="/" className="text-xl font-bold">
            {title}
          </Link>
        </div>
        <nav>
          <ul className="flex space-x-4">
            <li>
              <Button asChild variant="ghost">
                <Link href="/about">プロフィール</Link>
              </Button>
            </li>
            <li>
              <Button asChild variant="ghost">
                <Link href="/contact">お問い合わせ</Link>
              </Button>
            </li>
          </ul>
        </nav>
      </div>
    </header>
  );
}
```

#### フッターコンポーネント

```react:src/components/layout/footer.tsx
import { siteConfig } from "@/lib/config";
import Link from "next/link";

const { title, description } = siteConfig;

export function Footer() {
  return (
    <footer className="border-t py-8 bg-muted/40">
      <div className="container mx-auto px-4">
        <div className="flex flex-col md:flex-row justify-between items-center">
          <div className="mb-4 md:mb-0 flex flex-col items-center md:items-start">
            <h2 className="text-lg font-bold">{title}</h2>
            <p className="text-sm text-muted-foreground mt-1">{description}</p>
          </div>
          <div>
            <nav>
              <ul className="flex space-x-4 text-sm">
                <li>
                  <Link href="/" className="hover:underline">
                    ホーム
                  </Link>
                </li>
                <li>
                  <Link href="/about" className="hover:underline">
                    プロフィール
                  </Link>
                </li>
                <li>
                  <Link href="/contact" className="hover:underline">
                    お問い合わせ
                  </Link>
                </li>
                <li>
                  <Link href="/privacy" className="hover:underline">
                    プライバシー
                  </Link>
                </li>
              </ul>
            </nav>
            <p className="text-xs text-muted-foreground mt-4 text-center md:text-right">
              &copy; {new Date().getFullYear()} {title}
            </p>
          </div>
        </div>
      </div>
    </footer>
  );
}
```

#### サイト設定

サイト全体に関わる値を管理するファイルを作ります。

```react:src/lib/config.ts
/**
 * サイト全体の設定
 */
export const siteConfig = {
  title: "Qiita Blog",
  description: "Qiita APIを利用したBlogです",
  domain: "qiita-blog.com",
  url: "https://qiita-blog.com",
};
```

#### レイアウトの適用

ヘッダー、フッターをレイアウトに反映させます。
`metadata` を編集してOGP設定も行っています。

```react:src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Header } from "@/components/layout/header";
import { Footer } from "@/components/layout/footer";
import { siteConfig } from "@/lib/config";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

const baseURL = new URL(getBaseURL());

const { title, description } = siteConfig;

export const metadata: Metadata = {
  metadataBase: baseURL,
  formatDetection: { telephone: false, address: false, email: false },
  alternates: { canonical: "/", languages: { "ja-JP": "/ja-JP" } },
  title: {
    default: title,
    template: `%s | ${title}`,
    absolute: `${title} - ${description}`,
  },
  description,
  openGraph: {
    siteName: title,
    locale: "ja_JP",
    type: "website",
    title: title,
    description,
    url: getBaseURL(),
  },
  twitter: {
    card: "summary_large_image",
    title: title,
    description,
  },
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja" suppressHydrationWarning>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
      >
        <Header />
        <main className="flex-1 container mx-auto px-4 py-8">{children}</main>
        <Footer />
      </body>
    </html>
  );
}
```

#### ホームページの実装（仮）

```react:src/app/page.tsx
export default function Home() {
  return (
    <section>
      <h2 className="text-2xl font-bold mb-6">記事一覧</h2>
    </section>
  );
}
```

#### ユーティリティ関数の追加

vercelでは `VERCEL_PROJECT_PRODUCTION_URL` という環境変数が用意されています。
開発環境と本番環境の分岐を利用して `baseURL` を取得する関数を作っています。

```react:src/lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export const getBaseURL = () => {
  const url = process.env.VERCEL_PROJECT_PRODUCTION_URL;

  return url
    ? `https://${url}`
    : `http://localhost:${process.env.PORT || 3000}`;
};
```

### 4. ダークモードの実装

#### 必要なパッケージのインストール

```:terminal
$ bun add next-themes
```

https://ui.shadcn.com/docs/dark-mode/next

#### テーマプロバイダーの実装

```react:src/components/theme-provider.tsx
"use client"

import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"

export function ThemeProvider({
  children,
  ...props
}: React.ComponentProps<typeof NextThemesProvider>) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
```

#### レイアウトへのテーマプロバイダーの追加

テーマプロバイダーで `body` 内をラップします。
ラップされた部分だけダークモードが反映されるようになります。

```react:app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja" suppressHydrationWarning>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen flex flex-col`}
      >
        <ThemeProvider
           attribute="class"
           defaultTheme="system"
           enableSystem
           disableTransitionOnChange
         >
          <Header />
          <main className="flex-1 container mx-auto px-4 py-8">{children}</main>
          <Footer />
        </ThemeProvider>
      </body>
    </html>
  );
}
```

#### モード切替ボタンの実装

```react:src/components/mode-toggle.tsx
"use client";

import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";

export function ModeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === "light" ? "dark" : "light")}
    >
      <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
      <span className="sr-only">テーマを切り替え</span>
    </Button>
  );
}
```

#### ヘッダーへのモード切替ボタンの追加

```react:src/components/layout/header.tsx
import Link from "next/link";
import { siteConfig } from "@/lib/config";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/mode-toggle";

export function Header() {
  return (
    <header className="border-b">
      <div className="container mx-auto py-4 px-4 flex justify-between items-center">
        <div className="flex items-center gap-4">
          <Link href="/" className="text-xl font-bold">
            {siteConfig.title}
          </Link>
        </div>
        <nav>
          <ul className="flex items-center space-x-4">
            <li>
              <Button asChild variant="ghost">
                <Link href="/about">プロフィール</Link>
              </Button>
            </li>
            <li>
              <Button asChild variant="ghost">
                <Link href="/contact">お問い合わせ</Link>
              </Button>
            </li>
            <li>
              <ModeToggle />
            </li>
          </ul>
        </nav>
      </div>
    </header>
  );
}
```

これでダークモード機能が実装されました。

以下のようなレイアウトになっています。

**ライトモード**

![2025-05-27_10h08_17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183668/d8030af6-7c84-4011-a827-238b3f41105c.png)

**ダークモード**

![2025-05-27_10h08_20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183668/f6bf5168-3406-4c42-8f4b-aaccf8bc50c6.png)

### 5. APIの認証

箱枠が完成したので、 API 周りに着手しましょう。

Qiita の設定ページからアクセストークンを取得します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183668/bde656f3-f3c9-4b66-863f-770fda2ce808.png)

#### 環境変数の設定

```react:.env.local
QIITA_USER_ID=QiitaのユーザーID
QIITA_ACCESS_TOKEN=取得したアクセストークン
```

#### Qiita APIの設定

```react:src/lib/config.ts
/**
 * Qiita API設定
 */
export const qiitaConfig = {
  get userId(): string {
    return process.env.QIITA_USER_ID || "qiita";
  },

  accessToken: process.env.QIITA_ACCESS_TOKEN,
};
```

#### 認証済みユーザー情報の取得

```react:src/auth/qiita.ts
import { QiitaUser } from "@/types/qiita";
import { qiitaConfig } from "../lib/config";

export async function getQiitaUser(): Promise<QiitaUser> {
  if (!qiitaConfig.accessToken) {
    throw new Error("Qiita access token is not set");
  }

  const response = await fetch("https://qiita.com/api/v2/authenticated_user", {
    headers: {
      Authorization: `Bearer ${qiitaConfig.accessToken}`,
      "Content-Type": "application/json",
    },
    next: { revalidate: 3600 }, // 1時間キャッシュ
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch Qiita user: ${response.status}`);
  }

  return response.json();
}
```

### 6. 記事データの取得

記事データを取得する関数を2つ用意します。

**`fetchUserItems` 関数**

- 特定のユーザーのQiita記事一覧を取得する機能
- ページネーション対応（page, perPageパラメータ）

**`fetchItemDetail` 関数**

- 特定の記事IDに基づいて記事の詳細情報を取得する機能

```react:src/data/fetchQiitaData.ts
import type { QiitaItem } from "../types/qiita";

const BASE_URL = "https://qiita.com/api/v2";

/**
 * 指定したユーザーのQiita記事を取得する
 */
export async function fetchUserItems(
  userId: string,
  page = 1,
  perPage = 20
): Promise<QiitaItem[]> {
  const url = `${BASE_URL}/users/${userId}/items?page=${page}&per_page=${perPage}`;

  try {
    const response = await fetch(url, {
      headers: {
        "Content-Type": "application/json",
      },
      next: { revalidate: 3600 }, // 1時間キャッシュ
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch Qiita items: ${response.status}`);
    }

    return response.json();
  } catch (error) {
    console.error("Error fetching Qiita items:", error);
    return [];
  }
}

/**
 * 指定したIDの記事詳細を取得する
 */
export async function fetchItemDetail(
  itemId: string
): Promise<QiitaItem | null> {
  const url = `${BASE_URL}/items/${itemId}`;

  try {
    const response = await fetch(url, {
      headers: {
        "Content-Type": "application/json",
      },
      next: { revalidate: 3600 }, // 1時間キャッシュ
    });

    if (!response.ok) {
      throw new Error(`Failed to fetch Qiita item detail: ${response.status}`);
    }

    return response.json();
  } catch (error) {
    console.error("Error fetching Qiita item detail:", error);
    return null;
  }
}
```

### 7. 型の設定

```react:src/types/qiita.ts
export type QiitaTag = {
  name: string;
  versions: string[];
};

export type QiitaUser = {
  id: string;
  name: string;
  description: string;
  profile_image_url: string;
  organization: string | null;
  location: string | null;
  items_count: number;
  github_login_name: string | null;
  twitter_screen_name: string | null;
  website_url: string | null;
};

export type QiitaItem = {
  id: string;
  title: string;
  body: string;
  rendered_body: string;
  tags: QiitaTag[];
  created_at: string;
  updated_at: string;
  url: string;
  likes_count: number;
  comments_count: number;
  reactions_count: number;
  stocks_count: number;
  page_views_count: number | null;
  user: QiitaUser;
};
```

ここまでで、 API の接続から取得までができました。


### 8. 記事一覧の表示

実際に取得したデータを用いて、記事一覧を表示します

#### カードコンポーネント

記事ひとつずつを囲むカードコンポーネントを作成します。

```react:src/components/article/article-card.tsx
import Link from "next/link";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import type { QiitaItem } from "@/types/qiita";

interface ArticleCardProps {
  article: QiitaItem;
}

export function ArticleCard({ article }: ArticleCardProps) {
  // 日付をフォーマット
  const formattedDate = new Date(article.created_at).toLocaleDateString(
    "ja-JP",
    {
      year: "numeric",
      month: "long",
      day: "numeric",
    }
  );

  return (
    <Link
      href={`/articles/${article.id}`}
      className="block transition-opacity hover:opacity-80"
    >
      <Card className="h-full">
        <CardHeader className="pb-2">
          <CardTitle className="text-lg line-clamp-2">
            {article.title}
          </CardTitle>
        </CardHeader>
        <CardContent className="pb-2">
          <div className="flex gap-2 text-sm text-muted-foreground mb-2 flex-wrap">
            {article.tags.map((tag) => (
              <span
                key={tag.name}
                className="bg-muted px-2 py-1 rounded-md text-xs"
              >
                {tag.name}
              </span>
            ))}
          </div>
          <p className="text-sm text-muted-foreground line-clamp-3">
            {article.body
              .replace(/#+\s|```[\s\S]*?```|-|>|!\[.*\]\(.*\)/g, "")
              .slice(0, 150)}
            ...
          </p>
        </CardContent>
        <CardFooter className="text-xs text-muted-foreground pt-0">
          <div className="flex justify-between w-full">
            <span>{formattedDate}</span>
            <span>♥ {article.likes_count}</span>
          </div>
        </CardFooter>
      </Card>
    </Link>
  );
}
```

#### ホームページの実装

```react:src/app/page.tsx
import { fetchUserItems } from "@/data/fetchQiitaData";
import { qiitaConfig } from "@/lib/config";
import { ArticleCard } from "@/components/article/article-card";

export default async function Home() {
  console.log("QiitaユーザーID:", qiitaConfig.userId);

  // Qiitaの記事を取得
  const articles = await fetchUserItems(qiitaConfig.userId);

  console.log("取得した記事数:", articles.length);
  console.log("API応答:", articles.length ? "成功" : "空の配列");

  return (
    <div className="space-y-8">
      {/* デバッグ情報 */}
      <section className="p-4 bg-muted/50 rounded-lg mb-6">
        <h3 className="font-bold mb-2">API情報</h3>
        <p>Qiitaユーザー: {qiitaConfig.userId}</p>
        <p>取得記事数: {articles.length}</p>
        <p>
          ステータス: {articles.length ? "成功" : "記事が取得できませんでした"}
        </p>
      </section>

      <section>
        <h2 className="text-2xl font-bold mb-6">最新の記事</h2>

        {articles.length === 0 ? (
          <p className="text-muted-foreground">
            記事がまだありません。以下を確認してください：
            <ul className="list-disc ml-6 mt-2">
              <li>Qiitaユーザー名が正しいか確認してください</li>
              <li>当該ユーザーが公開記事を持っているか確認してください</li>
              <li>APIの制限に達していないか確認してください</li>
            </ul>
          </p>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
            {articles.map((article) => (
              <ArticleCard key={article.id} article={article} />
            ))}
          </div>
        )}
      </section>
    </div>
  );
}
```

ホームページに記事一覧が表示されました。

![2025-05-25_23h14_56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183668/98ba3eb2-7802-4e98-98dc-4cc2cdc44e4e.png)