第2部へようこそ!

第1部で「Xでログイン」ボタンが動くようになりましたね。おめでとうございます!

でも、「ログインできただけで、何も表示されない...」と思っていませんか?

安心してください。この第2部では、自分のツイートを画面に表示します。

この第2部のゴール

ログイン後、自分の最新ツイート5件が画面に表示される

たったこれだけです。しかし、ここまでできれば、第3部のツイート投稿・削除は簡単になります。


3部作の進捗状況

  • 第1部(完了): ログイン機能
  • 🔄 第2部(今回): ツイート表示機能
  • 第3部(次回): ツイート投稿・削除機能

目次

  1. Twitter APIからデータを取得する仕組み
  2. 型定義を作成する
  3. APIルートを作成する
  4. クライアント側APIクライアントを作成する
  5. UIコンポーネントを作成する
  6. 動作確認
  7. よくあるエラーと解決方法

1. Twitter APIからデータを取得する仕組み

1-1. なぜ直接Twitter APIを呼べないのか?

第1部で取得した「アクセストークン」があれば、Twitter APIを呼べます。

しかし、ブラウザから直接Twitter APIを呼ぶのは危険です。なぜなら:

❌ アクセストークンがブラウザに公開される
❌ 悪意のあるユーザーがトークンを盗める
❌ 不正にツイートを投稿されるかもしれない

そこで、以下のような流れにします:

1. ブラウザ → 自分のAPIサーバー: 「ツイートが欲しい」
2. 自分のAPIサーバー → Twitter API: アクセストークンを使ってリクエスト
3. Twitter API → 自分のAPIサーバー: ツイートデータを返す
4. 自分のAPIサーバー → ブラウザ: ツイートデータを返す

重要なポイント:

  • アクセストークンはサーバー側でのみ使用
  • ブラウザにはトークンを公開しない
  • セキュリティが保たれる

1-2. Next.jsのAPIルートとは?

Next.jsには、APIルートという機能があります。

app/api/フォルダの中にroute.tsファイルを作ると、自動的にAPIエンドポイントになります。

例:

app/api/twitter/tweets/route.ts
→ http://localhost:3000/api/twitter/tweets

このAPIルートを使って、Twitter APIを呼び出します。


2. 型定義を作成する

TypeScriptを使っているので、まずはツイートのデータ構造を定義します。

2-1. 型定義ファイルを作成

types/tweet.tsを作成します:

touch types/tweet.ts

Windowsの場合:

エクスプローラーでtypesフォルダ内にtweet.tsファイルを作成してください。

2-2. ツイートの型を定義

types/tweet.ts:

// ツイートの型定義

export interface Tweet {
    id: string;
    text: string;
    created_at: string;
    author_id: string;
    public_metrics: {
        retweet_count: number;
        reply_count: number;
        like_count: number;
        quote_count: number;
    };
}

export interface TwitterUser {
    id: string;
    name: string;
    username: string;
    profile_image_url?: string;
}

export interface TwitterAPIResponse {
    data: Tweet[];
    includes?: {
        users?: TwitterUser[];
    };
    meta?: {
        result_count: number;
        newest_id?: string;
        oldest_id?: string;
    };
}

これは何をしているのか?

Twitter APIから返されるデータの構造を定義しています。

フィールド意味
idツイートのID
textツイートの本文
created_at投稿日時
author_id投稿者のID
public_metricsいいね数、リツイート数など

なぜ型定義が必要?

✅ タイプミスを防げる
✅ IDEの自動補完が効く
✅ データの構造が一目でわかる


3. APIルートを作成する

3-1. ユーザー情報取得APIを作る

まず、自分のユーザーIDを取得するAPIを作ります。

フォルダを作成:

mkdir -p app/api/twitter/me

app/api/twitter/me/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";

const TWITTER_API_BASE = "https://api.twitter.com/2";

export async function GET(request: NextRequest) {
    try {
        // セッションを取得
        const session = await getServerSession(authOptions);

        // ログインしているか確認
        if (!session?.accessToken) {
            return NextResponse.json(
                { error: "Unauthorized" },
                { status: 401 }
            );
        }

        // Twitter APIを呼び出す
        const url = `${TWITTER_API_BASE}/users/me`;

        const response = await fetch(url, {
            headers: {
                Authorization: `Bearer ${session.accessToken}`,
                "Content-Type": "application/json",
            },
        });

        // エラー処理
        if (!response.ok) {
            const error = await response.json();
            console.error("Twitter API error:", error);
            return NextResponse.json(
                { error: error.detail || "Failed to fetch user info" },
                { status: response.status }
            );
        }

        const data = await response.json();
        return NextResponse.json(data);
    } catch (error) {
        console.error("Error in /api/twitter/me:", error);
        return NextResponse.json(
            { error: "Internal server error" },
            { status: 500 }
        );
    }
}

コードの説明:

セッションの取得

const session = await getServerSession(authOptions);

第1部で保存したアクセストークンを取得します。

Twitter APIを呼び出す

const url = `${TWITTER_API_BASE}/users/me`;

const response = await fetch(url, {
    headers: {
        Authorization: `Bearer ${session.accessToken}`,
        "Content-Type": "application/json",
    },
});

重要なポイント:

  • Authorizationヘッダーにアクセストークンを含める
  • Bearerという形式で送る(OAuth 2.0の標準)

エラー処理

if (!response.ok) {
    const error = await response.json();
    console.error("Twitter API error:", error);
    return NextResponse.json(
        { error: error.detail || "Failed to fetch user info" },
        { status: response.status }
    );
}

Twitter APIからエラーが返された場合、クライアントにエラーメッセージを返します。

3-2. ツイート一覧取得APIを作る

次に、自分のツイート一覧を取得するAPIを作ります。

フォルダを作成:

mkdir -p app/api/twitter/tweets

app/api/twitter/tweets/route.ts:

import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";

const TWITTER_API_BASE = "https://api.twitter.com/2";

export async function GET(request: NextRequest) {
    try {
        const session = await getServerSession(authOptions);

        if (!session?.accessToken) {
            return NextResponse.json(
                { error: "Unauthorized" },
                { status: 401 }
            );
        }

        // クエリパラメータを取得
        const { searchParams } = new URL(request.url);
        const userId = searchParams.get("userId");
        const maxResults = searchParams.get("maxResults") || "5";

        if (!userId) {
            return NextResponse.json(
                { error: "userId is required" },
                { status: 400 }
            );
        }

        // Twitter APIを呼び出す
        const url = `${TWITTER_API_BASE}/users/${userId}/tweets?max_results=${maxResults}&tweet.fields=created_at,public_metrics,author_id`;

        const response = await fetch(url, {
            headers: {
                Authorization: `Bearer ${session.accessToken}`,
                "Content-Type": "application/json",
            },
        });

        // レート制限のチェック
        if (response.status === 429) {
            const rateLimitReset = response.headers.get('x-rate-limit-reset');
            const resetTime = rateLimitReset ? parseInt(rateLimitReset) : Math.floor(Date.now() / 1000) + 900;
            const waitSeconds = Math.max(0, resetTime - Math.floor(Date.now() / 1000));
            const waitMinutes = Math.ceil(waitSeconds / 60);

            return NextResponse.json(
                {
                    error: `API制限に達しました。約${waitMinutes}分後に再度お試しください。`,
                    rateLimitExceeded: true,
                    retryAfter: waitSeconds,
                },
                { status: 429 }
            );
        }

        if (!response.ok) {
            const error = await response.json();
            return NextResponse.json(
                { error: error.detail || "Failed to fetch tweets" },
                { status: response.status }
            );
        }

        const data = await response.json();
        return NextResponse.json(data);
    } catch (error) {
        console.error("Error in /api/twitter/tweets:", error);
        return NextResponse.json(
            { error: "Internal server error" },
            { status: 500 }
        );
    }
}

コードの説明:

クエリパラメータの取得

const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
const maxResults = searchParams.get("maxResults") || "5";

URLからuserIdmaxResultsを取得します。

例:/api/twitter/tweets?userId=123&maxResults=5

Twitter APIのパラメータ

const url = `${TWITTER_API_BASE}/users/${userId}/tweets?max_results=${maxResults}&tweet.fields=created_at,public_metrics,author_id`;

パラメータの意味:

パラメータ意味
max_results取得するツイート数(最大100)
tweet.fields取得するフィールドを指定

なぜtweet.fieldsが必要?

デフォルトでは、Twitter APIはidtextしか返しません。投稿日時やいいね数を取得するには、tweet.fieldsで明示的に指定する必要があります。

レート制限への対応

if (response.status === 429) {
    // レート制限に達した場合の処理
}

Twitter API Free Tierには制限があります:

  • ツイート読み取り:月10,000回

429エラーが返された場合、待ち時間を計算してユーザーに通知します。


4. クライアント側APIクライアントを作成する

4-1. APIクライアントファイルを作成

lib/twitter-api.ts:

// Twitter APIクライアント

import { Tweet, TwitterAPIResponse } from '@/types/tweet';

/**
 * ユーザー情報を取得
 */
export async function getMe(): Promise<{ id: string; username: string; name: string }> {
    try {
        const response = await fetch('/api/twitter/me');

        if (!response.ok) {
            const error = await response.json();
            throw new Error(error.error || 'Failed to fetch user info');
        }

        const data = await response.json();
        return data.data;
    } catch (error) {
        console.error('Error fetching user info:', error);
        throw error;
    }
}

/**
 * ユーザーのツイートを取得
 */
export async function getUserTweets(
    userId: string,
    maxResults: number = 5
): Promise<Tweet[]> {
    try {
        const url = `/api/twitter/tweets?userId=${userId}&maxResults=${maxResults}`;

        const response = await fetch(url);

        if (!response.ok) {
            const error = await response.json();
            throw new Error(error.error || 'Failed to fetch tweets');
        }

        const data: TwitterAPIResponse = await response.json();
        return data.data || [];
    } catch (error) {
        console.error('Error fetching tweets:', error);
        throw error;
    }
}

これは何をしているのか?

ブラウザから自分のAPIルートを呼び出す関数を定義しています。

なぜ別ファイルにするのか?

✅ コードの再利用ができる
✅ エラーハンドリングを一箇所にまとめられる
✅ 複数のコンポーネントから使える


5. UIコンポーネントを作成する

5-1. ユーティリティ関数を作成

日付をフォーマットする関数を作ります。

lib/utils.ts:

/**
 * 日付を「◯分前」「◯時間前」形式にフォーマット
 */
export function formatDate(dateString: string): string {
    const date = new Date(dateString);
    const now = new Date();
    const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);

    if (diffInSeconds < 60) {
        return `${diffInSeconds}秒前`;
    }

    const diffInMinutes = Math.floor(diffInSeconds / 60);
    if (diffInMinutes < 60) {
        return `${diffInMinutes}分前`;
    }

    const diffInHours = Math.floor(diffInMinutes / 60);
    if (diffInHours < 24) {
        return `${diffInHours}時間前`;
    }

    const diffInDays = Math.floor(diffInHours / 24);
    if (diffInDays < 7) {
        return `${diffInDays}日前`;
    }

    return date.toLocaleDateString('ja-JP', {
        year: 'numeric',
        month: 'long',
        day: 'numeric'
    });
}

これは何をしているのか?

Twitterのように「3分前」「2時間前」という形式で日付を表示します。

5-2. ツイート1件を表示するコンポーネント

components/フォルダを作成:

mkdir components

components/TweetItem.tsx:

"use client";

import { Tweet } from "@/types/tweet";
import { formatDate } from "@/lib/utils";

interface TweetItemProps {
    tweet: Tweet;
    username: string;
}

export default function TweetItem({ tweet, username }: TweetItemProps) {
    return (
        <div className="border-b border-gray-200 p-4 hover:bg-gray-50 transition-colors">
            <div className="flex items-start gap-3">
                {/* アイコン */}
                <div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold">
                    {username.charAt(0).toUpperCase()}
                </div>

                {/* ツイート本文 */}
                <div className="flex-1 min-w-0">
                    {/* ユーザー名と日時 */}
                    <div className="flex items-center gap-2 mb-1">
                        <span className="font-semibold text-gray-900">
                            @{username}
                        </span>
                        <span className="text-sm text-gray-500">
                            {formatDate(tweet.created_at)}
                        </span>
                    </div>

                    {/* ツイート本文 */}
                    <p className="text-gray-900 whitespace-pre-wrap break-words">
                        {tweet.text}
                    </p>

                    {/* いいね数、リツイート数 */}
                    <div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
                        <span>💬 {tweet.public_metrics.reply_count}</span>
                        <span>🔁 {tweet.public_metrics.retweet_count}</span>
                        <span>❤️ {tweet.public_metrics.like_count}</span>
                    </div>
                </div>
            </div>
        </div>
    );
}

コードの説明:

  • アイコン:ユーザー名の最初の文字を表示(プロフィール画像の代わり)
  • 日時formatDate関数で「◯分前」形式に変換
  • 本文whitespace-pre-wrapで改行を保持
  • メトリクス:リプライ数、リツイート数、いいね数を表示

5-3. ツイート一覧を表示するコンポーネント

components/TweetList.tsx:

"use client";

import { Tweet } from "@/types/tweet";
import TweetItem from "./TweetItem";

interface TweetListProps {
    tweets: Tweet[];
    username: string;
    isLoading: boolean;
    isRefreshing: boolean;
    onRefresh: () => void;
}

export default function TweetList({
    tweets,
    username,
    isLoading,
    isRefreshing,
    onRefresh,
}: TweetListProps) {
    if (isLoading) {
        return (
            <div className="bg-white border border-gray-200 rounded-lg p-6">
                <p className="text-center text-gray-500">読み込み中...</p>
            </div>
        );
    }

    return (
        <div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
            {/* ヘッダー */}
            <div className="border-b border-gray-200 p-4 flex items-center justify-between">
                <h2 className="text-lg font-semibold text-gray-900">
                    最近の投稿
                </h2>
                <button
                    onClick={onRefresh}
                    disabled={isRefreshing}
                    className="text-sm text-blue-500 hover:text-blue-600 disabled:opacity-50"
                >
                    {isRefreshing ? "更新中..." : "🔄 更新"}
                </button>
            </div>

            {/* ツイート一覧 */}
            {tweets.length === 0 ? (
                <div className="p-6 text-center text-gray-500">
                    まだツイートがありません
                </div>
            ) : (
                <div>
                    {tweets.map((tweet) => (
                        <TweetItem
                            key={tweet.id}
                            tweet={tweet}
                            username={username}
                        />
                    ))}
                </div>
            )}
        </div>
    );
}

コードの説明:

  • ローディング状態isLoadingtrueの間は「読み込み中...」を表示
  • 更新ボタン:クリックするとonRefresh関数が呼ばれる
  • 空の状態:ツイートがない場合は「まだツイートがありません」を表示
  • 一覧表示map関数で各ツイートをTweetItemコンポーネントで表示

5.4. メインページを更新

app/page.tsxを更新して、ツイートを表示します:

"use client";

import { useSession, signIn, signOut } from "next-auth/react";
import { useState, useEffect, useCallback } from "react";
import { Tweet } from "@/types/tweet";
import { getUserTweets, getMe } from "@/lib/twitter-api";
import TweetList from "@/components/TweetList";

export default function Home() {
    const { data: session, status } = useSession();
    const [tweets, setTweets] = useState<Tweet[]>([]);
    const [userInfo, setUserInfo] = useState<{ id: string; username: string; name: string } | null>(null);
    const [isLoading, setIsLoading] = useState(true);
    const [isRefreshing, setIsRefreshing] = useState(false);
    const [error, setError] = useState<string | null>(null);

    // ユーザー情報を取得
    const fetchUserInfo = useCallback(async () => {
        try {
            const info = await getMe();
            setUserInfo(info);
            return info;
        } catch (err) {
            console.error("Failed to fetch user info:", err);
            setError("ユーザー情報の取得に失敗しました");
            return null;
        }
    }, []);

    // ツイートを取得
    const fetchTweets = useCallback(async (userId: string) => {
        try {
            const fetchedTweets = await getUserTweets(userId, 5);
            setTweets(fetchedTweets);
            setError(null);
        } catch (err) {
            console.error("Failed to fetch tweets:", err);
            setError("投稿の取得に失敗しました");
        } finally {
            setIsLoading(false);
            setIsRefreshing(false);
        }
    }, []);

    // 初回ロード
    useEffect(() => {
        if (session) {
            const loadData = async () => {
                const info = await fetchUserInfo();
                if (info) {
                    await fetchTweets(info.id);
                }
            };
            loadData();
        } else {
            setIsLoading(false);
        }
    }, [session, fetchUserInfo, fetchTweets]);

    // ツイートを更新
    const handleRefresh = async () => {
        if (!session || !userInfo) return;

        setIsRefreshing(true);
        await fetchTweets(userInfo.id);
    };

    if (status === "loading") {
        return (
            <div className="flex min-h-screen items-center justify-center">
                <p className="text-gray-500">読み込み中...</p>
            </div>
        );
    }

    if (!session) {
        return (
            <div className="flex min-h-screen flex-col items-center justify-center bg-gray-50">
                <div className="space-y-6 text-center">
                    <div className="space-y-2">
                        <h1 className="text-4xl font-bold text-gray-900">X My Posts</h1>
                        <p className="text-gray-600">
                            自分の投稿に集中できるシンプルなXクライアント
                        </p>
                    </div>
                    <button
                        onClick={() => signIn("twitter")}
                        className="px-6 py-3 bg-blue-500 text-white font-medium rounded-full hover:bg-blue-600 transition-colors"
                    >
                        X (Twitter) でサインイン
                    </button>
                </div>
            </div>
        );
    }

    return (
        <div className="min-h-screen bg-gray-50">
            {/* ヘッダー */}
            <header className="bg-white border-b border-gray-200 sticky top-0 z-10">
                <div className="max-w-2xl mx-auto px-4 py-4 flex items-center justify-between">
                    <h1 className="text-xl font-bold text-gray-900">X My Posts</h1>
                    <div className="flex items-center gap-4">
                        <span className="text-sm text-gray-600">
                            @{userInfo?.username || session.user?.name}
                        </span>
                        <button
                            onClick={() => signOut()}
                            className="text-sm text-gray-600 hover:text-gray-900"
                        >
                            サインアウト
                        </button>
                    </div>
                </div>
            </header>

            {/* メインコンテンツ */}
            <main className="max-w-2xl mx-auto px-4 py-6 space-y-6">
                {/* エラーメッセージ */}
                {error && (
                    <div className="bg-red-50 border border-red-200 rounded-lg p-4">
                        <p className="text-red-600 text-sm">{error}</p>
                    </div>
                )}

                {/* ツイート一覧 */}
                <TweetList
                    tweets={tweets}
                    username={userInfo?.username || ""}
                    isLoading={isLoading}
                    isRefreshing={isRefreshing}
                    onRefresh={handleRefresh}
                />
            </main>
        </div>
    );
}

コードの説明:

状態管理

const [tweets, setTweets] = useState<Tweet[]>([]);
const [userInfo, setUserInfo] = useState<...>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);

各状態の意味:

状態意味
tweets取得したツイート一覧
userInfoユーザー情報(ID、ユーザー名)
isLoading初回ロード中か
isRefreshing更新ボタンを押した後か
errorエラーメッセージ

データ取得の流れ

useEffect(() => {
    if (session) {
        const loadData = async () => {
            const info = await fetchUserInfo();  // 1. ユーザー情報を取得
            if (info) {
                await fetchTweets(info.id);      // 2. ツイートを取得
            }
        };
        loadData();
    }
}, [session, fetchUserInfo, fetchTweets]);

流れ:

  1. ログイン後、useEffectが実行される
  2. fetchUserInfoでユーザーIDを取得
  3. そのIDを使ってfetchTweetsでツイートを取得

6. 動作確認

6-1. 開発サーバーを起動

ターミナルで以下を実行します:

npm run dev

ブラウザでhttp://localhost:3000を開きます。

6-2. ログインしてツイートを確認

手順:

  1. 「X (Twitter) でサインイン」ボタンをクリック
  2. Twitter認証を完了
  3. 自分の最新ツイート5件が表示される!

成功です!

6-3. 更新ボタンを試す

「🔄 更新」ボタンをクリックすると、最新のツイートが再取得されます。


7. よくあるエラーと解決方法

エラー1: 「Failed to fetch user info」

症状:

ユーザー情報が取得できず、エラーメッセージが表示される。

原因:

  1. アクセストークンが期限切れ
  2. Twitter APIのレート制限に達した
  3. ネットワークエラー

解決方法:

  1. サインアウトして再サインイン
  2. 少し待ってから再度試す
  3. ブラウザの開発者ツールでネットワークエラーを確認

エラー2: 「Failed to fetch tweets」

症状:

ツイート一覧が表示されず、エラーメッセージが表示される。

原因:

  1. userIdが正しく渡されていない
  2. Twitter APIのレート制限
  3. ツイートが0件

解決方法:

開発者ツールでデバッグ:

  1. F12キーを押して開発者ツールを開く
  2. Consoleタブでエラーログを確認
  3. NetworkタブでAPIリクエストを確認

よくあるパターン:

Failed to fetch tweets: userId is required

→ ユーザー情報の取得に失敗している可能性

エラー3: 「API制限に達しました」

症状:

「約15分後に再度お試しください」というメッセージが表示される。

原因:

Twitter API Free Tierのレート制限に達した。

解決方法:

✅ 15分待つ
✅ リロードを繰り返さない
✅ 不要なAPI呼び出しを減らす

レート制限の詳細:

エンドポイント制限
ユーザー情報取得15分あたり900回
ツイート取得15分あたり900回

開発中は問題になりませんが、本番環境では注意が必要です。

エラー4: ツイートが表示されない

症状:

エラーはないが、ツイートが0件と表示される。

原因:

  1. まだツイートを投稿していない
  2. APIが古いツイートしか取得できていない

解決方法:

  1. Twitterアプリで新しいツイートを投稿
  2. 「🔄 更新」ボタンをクリック
  3. それでも表示されない場合、開発者ツールでレスポンスを確認

まとめ

この記事でできたこと

✅ Twitter APIからデータを取得する仕組みを理解した
✅ APIルートを作成した(ユーザー情報、ツイート一覧)
✅ クライアント側APIクライアントを作成した
✅ UIコンポーネントを作成した(ツイート表示)
自分の最新ツイート5件が画面に表示された!

次の第3部では

第3部では、ツイート投稿・削除機能を実装します。

内容:

  • ツイート投稿APIの実装
  • ツイート削除APIの実装
  • ツイート作成コンポーネント
  • リアルタイムプレビュー機能
  • よくあるエラー(403 Forbidden)と解決方法

第2部でツイート表示ができているので、第3部はもっと楽しいです!


参考資料

公式ドキュメント

Twitter API関連

React Hooks関連

エラーハンドリング

お疲れさまでした!🎉

次の第3部で、ついに完成します!