第3部へようこそ!完成まであと少し!

第2部で自分のツイートが画面に表示されるようになりましたね。素晴らしい!

でも、「見るだけじゃつまらない...投稿もしたい!」と思っていませんか?

安心してください。この第3部で、ついにアプリが完成します。

この第3部のゴール

ツイートを投稿・削除できる完全なTwitterクライアントアプリの完成

この第3部を終えると:

✅ テキストボックスにツイートを入力して投稿できる
✅ 投稿前にプレビューで確認できる
✅ 自分のツイートを削除できる
✅ リアルタイムで画面が更新される


3部作の進捗状況

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

目次

  1. ツイート投稿の仕組み
  2. ツイート投稿APIを作成する
  3. ツイート削除APIを作成する
  4. APIクライアントを更新する
  5. UIコンポーネントを作成する
  6. 動作確認
  7. よくあるエラーと解決方法
  8. さらに改善するアイデア

1. ツイート投稿の仕組み

1-1. Twitter APIでツイートを投稿する

Twitter API v2では、以下のエンドポイントでツイートを投稿します:

POST https://api.twitter.com/2/tweets

リクエストボディ:

{
  "text": "投稿するツイート本文"
}

重要なポイント:

  • ツイートは280文字以内
  • tweet.writeスコープが必要(第1部で設定済み)
  • アクセストークンが必要

1-2. ツイート削除の仕組み

ツイート削除は以下のエンドポイントを使います:

DELETE https://api.twitter.com/2/tweets/{tweet_id}

重要なポイント:

  • 自分のツイートのみ削除可能
  • 削除したツイートは復元できない

1-3. 実装の流れ

1. ユーザーがテキストボックスに入力
2. 「投稿する」ボタンをクリック
3. プレビュー画面で確認
4. 「投稿」をクリック
5. Next.js APIルート → Twitter API
6. 成功したら画面を更新

2. ツイート投稿APIを作成する

2-1. 型定義を追加

まず、types/tweet.tsにツイート投稿の型を追加します:

types/tweet.tsに以下を追加:

export interface CreateTweetRequest {
    text: string;
}

export interface CreateTweetResponse {
    data: {
        id: string;
        text: string;
    };
}

export interface DeleteTweetResponse {
    data: {
        deleted: boolean;
    };
}

2-2. ツイート投稿APIルートを作成

フォルダを作成:

mkdir -p app/api/twitter/create

app/api/twitter/create/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 POST(request: NextRequest) {
    try {
        const session = await getServerSession(authOptions);

        console.log("Session in /api/twitter/create:", {
            hasSession: !!session,
            hasAccessToken: !!session?.accessToken,
        });

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

        // リクエストボディを取得
        const body = await request.json();
        const { text } = body;

        // バリデーション
        if (!text || !text.trim()) {
            return NextResponse.json(
                { error: "Tweet text is required" },
                { status: 400 }
            );
        }

        if (text.length > 280) {
            return NextResponse.json(
                { error: "Tweet text must be 280 characters or less" },
                { status: 400 }
            );
        }

        const url = `${TWITTER_API_BASE}/tweets`;

        console.log("Creating tweet:", {
            url,
            textLength: text.length,
        });

        // Twitter APIを呼び出す
        const response = await fetch(url, {
            method: "POST",
            headers: {
                Authorization: `Bearer ${session.accessToken}`,
                "Content-Type": "application/json",
            },
            body: JSON.stringify({ text }),
        });

        console.log("Twitter API response:", {
            status: response.status,
            statusText: response.statusText,
            ok: response.ok,
        });

        if (!response.ok) {
            const error = await response.json();
            console.error("Twitter API error response:", JSON.stringify(error, null, 2));
            return NextResponse.json(
                { error: error.detail || error.title || "Failed to create tweet" },
                { status: response.status }
            );
        }

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

コードの説明:

バリデーション

if (!text || !text.trim()) {
    return NextResponse.json(
        { error: "Tweet text is required" },
        { status: 400 }
    );
}

if (text.length > 280) {
    return NextResponse.json(
        { error: "Tweet text must be 280 characters or less" },
        { status: 400 }
    );
}

なぜバリデーションが必要?

  • 空のツイートを投稿できないようにする
  • 280文字制限を守る
  • Twitter APIに無駄なリクエストを送らない

Twitter APIを呼び出す

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

重要なポイント:

  • method: "POST":POSTリクエスト
  • Authorization:アクセストークンを含める
  • body:ツイート本文をJSON形式で送る

エラーハンドリング

if (!response.ok) {
    const error = await response.json();
    console.error("Twitter API error response:", JSON.stringify(error, null, 2));
    return NextResponse.json(
        { error: error.detail || error.title || "Failed to create tweet" },
        { status: response.status }
    );
}

Twitter APIからエラーが返された場合、詳細なログを出力します。これにより、403 Forbiddenなどのエラーをデバッグできます。


3. ツイート削除APIを作成する

3-1. ツイート削除APIルートを作成

フォルダを作成:

mkdir -p app/api/twitter/delete/\[id\]

Windowsの場合:

エクスプローラーで手動でフォルダを作成:

app → api → twitter → delete → [id]

app/api/twitter/delete/[id]/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 DELETE(
    request: NextRequest,
    { params }: { params: { id: string } }
) {
    try {
        const session = await getServerSession(authOptions);

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

        const tweetId = params.id;

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

        const url = `${TWITTER_API_BASE}/tweets/${tweetId}`;

        console.log("Deleting tweet:", {
            url,
            tweetId,
        });

        // Twitter APIを呼び出す
        const response = await fetch(url, {
            method: "DELETE",
            headers: {
                Authorization: `Bearer ${session.accessToken}`,
                "Content-Type": "application/json",
            },
        });

        console.log("Twitter API response:", {
            status: response.status,
            statusText: response.statusText,
            ok: response.ok,
        });

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

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

コードの説明:

動的ルート

export async function DELETE(
    request: NextRequest,
    { params }: { params: { id: string } }
) {
    const tweetId = params.id;
}

[id]フォルダの意味:

  • /api/twitter/delete/123456789というURLにアクセス
  • params.id123456789を取得できる

これにより、どのツイートを削除するかをURLで指定できます。

DELETEリクエスト

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

POSTの代わりにDELETEメソッドを使います。


4. APIクライアントを更新する

4-1. クライアント側API関数を追加

lib/twitter-api.tsに以下を追加:

/**
 * ツイートを投稿
 */
export async function createTweet(
    text: string
): Promise<CreateTweetResponse> {
    try {
        const url = `/api/twitter/create`;

        const response = await fetch(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({ text }),
        });

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

        return await response.json();
    } catch (error) {
        console.error('Error creating tweet:', error);
        throw error;
    }
}

/**
 * ツイートを削除
 */
export async function deleteTweet(
    tweetId: string
): Promise<boolean> {
    try {
        const url = `/api/twitter/delete/${tweetId}`;

        const response = await fetch(url, {
            method: 'DELETE',
        });

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

        const data: DeleteTweetResponse = await response.json();
        return data.data.deleted;
    } catch (error) {
        console.error('Error deleting tweet:', error);
        throw error;
    }
}

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

ブラウザから自分のAPIルート(/api/twitter/createなど)を呼び出す関数です。


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

5-1. バリデーション関数を追加

lib/utils.tsに以下を追加:

/**
 * ツイートのバリデーション
 */
export interface TweetValidation {
    isValid: boolean;
    error?: string;
}

export function validateTweetText(text: string): TweetValidation {
    if (!text || !text.trim()) {
        return {
            isValid: false,
            error: "ツイートを入力してください"
        };
    }

    if (text.length > 280) {
        return {
            isValid: false,
            error: "ツイートは280文字以内で入力してください"
        };
    }

    return { isValid: true };
}

5-2. ツイートプレビューコンポーネント

components/TweetPreview.tsx:

"use client";

interface TweetPreviewProps {
    text: string;
    onConfirm: () => void;
    onCancel: () => void;
    isPosting: boolean;
}

export default function TweetPreview({
    text,
    onConfirm,
    onCancel,
    isPosting,
}: TweetPreviewProps) {
    return (
        <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
            <div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
                <h3 className="text-lg font-semibold mb-4">プレビュー</h3>
                
                <div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-4">
                    <p className="whitespace-pre-wrap break-words text-gray-900">
                        {text}
                    </p>
                </div>

                <div className="flex gap-3 justify-end">
                    <button
                        onClick={onCancel}
                        disabled={isPosting}
                        className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
                    >
                        キャンセル
                    </button>
                    <button
                        onClick={onConfirm}
                        disabled={isPosting}
                        className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50"
                    >
                        {isPosting ? "投稿中..." : "投稿する"}
                    </button>
                </div>
            </div>
        </div>
    );
}

コードの説明:

  • モーダル表示fixed inset-0で画面全体を覆う
  • 背景の暗転bg-black bg-opacity-50で半透明の黒背景
  • プレビュー:投稿する内容を確認できる
  • ローディング状態:投稿中はボタンを無効化

5-3. ツイート作成コンポーネント

components/TweetComposer.tsx:

"use client";

import { useState } from "react";
import { validateTweetText } from "@/lib/utils";
import TweetPreview from "./TweetPreview";

interface TweetComposerProps {
    onTweetCreated: () => void;
    onCreateTweet: (text: string) => Promise<void>;
}

export default function TweetComposer({
    onTweetCreated,
    onCreateTweet,
}: TweetComposerProps) {
    const [text, setText] = useState("");
    const [showPreview, setShowPreview] = useState(false);
    const [isPosting, setIsPosting] = useState(false);
    const [error, setError] = useState<string | null>(null);

    const characterCount = text.length;
    const remainingCharacters = 280 - characterCount;

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();

        const validation = validateTweetText(text);
        if (!validation.isValid) {
            setError(validation.error || null);
            return;
        }

        setError(null);
        setShowPreview(true);
    };

    const handleConfirmPost = async () => {
        setIsPosting(true);
        setError(null);

        try {
            await onCreateTweet(text);
            setText("");
            setShowPreview(false);
            onTweetCreated();
        } catch (err) {
            setError(err instanceof Error ? err.message : "投稿に失敗しました");
            setShowPreview(false);
        } finally {
            setIsPosting(false);
        }
    };

    const handleCancelPreview = () => {
        setShowPreview(false);
    };

    return (
        <>
            <div className="border border-gray-200 rounded-lg p-4 bg-white">
                <form onSubmit={handleSubmit} className="space-y-3">
                    <textarea
                        value={text}
                        onChange={(e) => setText(e.target.value)}
                        placeholder="今何してる?"
                        className="w-full min-h-[120px] p-3 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                        disabled={isPosting}
                    />

                    {error && (
                        <p className="text-sm text-red-500">{error}</p>
                    )}

                    <div className="flex items-center justify-between">
                        <span
                            className={`text-sm ${
                                remainingCharacters < 0
                                    ? "text-red-500 font-bold"
                                    : remainingCharacters < 20
                                        ? "text-orange-500"
                                        : "text-gray-500"
                            }`}
                        >
                            {remainingCharacters < 0 && "-"}
                            {Math.abs(remainingCharacters)} / 280
                        </span>

                        <button
                            type="submit"
                            disabled={!text.trim() || remainingCharacters < 0 || isPosting}
                            className="px-6 py-2 bg-blue-500 text-white font-medium rounded-full hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
                        >
                            投稿する
                        </button>
                    </div>
                </form>
            </div>

            {showPreview && (
                <TweetPreview
                    text={text}
                    onConfirm={handleConfirmPost}
                    onCancel={handleCancelPreview}
                    isPosting={isPosting}
                />
            )}
        </>
    );
}

コードの説明:

文字数カウント

const characterCount = text.length;
const remainingCharacters = 280 - characterCount;

リアルタイムで残り文字数を表示します。

色分け

className={`text-sm ${
    remainingCharacters < 0
        ? "text-red-500 font-bold"      // 超過:赤色
        : remainingCharacters < 20
            ? "text-orange-500"          // 20文字以下:オレンジ
            : "text-gray-500"            // 通常:グレー
}`}

残り文字数に応じて色が変わります。

投稿の流れ

  1. ユーザーが「投稿する」ボタンをクリック
  2. handleSubmitでバリデーション
  3. プレビューを表示
  4. 「投稿する」をクリック
  5. handleConfirmPostで実際に投稿
  6. 成功したらonTweetCreatedを呼ぶ(画面を更新)

5-4. ツイートアイテムに削除ボタンを追加

components/TweetItem.tsxを更新:

"use client";

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

interface TweetItemProps {
    tweet: Tweet;
    username: string;
    onDelete?: (tweetId: string) => void;
    isDeleting?: boolean;
}

export default function TweetItem({ 
    tweet, 
    username, 
    onDelete,
    isDeleting = false,
}: 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 justify-between mb-1">
                        <div className="flex items-center gap-2">
                            <span className="font-semibold text-gray-900">
                                @{username}
                            </span>
                            <span className="text-sm text-gray-500">
                                {formatDate(tweet.created_at)}
                            </span>
                        </div>

                        {/* 削除ボタン */}
                        {onDelete && (
                            <button
                                onClick={() => onDelete(tweet.id)}
                                disabled={isDeleting}
                                className="text-sm text-red-500 hover:text-red-600 disabled:opacity-50"
                            >
                                {isDeleting ? "削除中..." : "🗑️ 削除"}
                            </button>
                        )}
                    </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>
    );
}

追加した機能:

  • onDeleteプロップ:削除ボタンがクリックされたときに呼ばれる
  • isDeletingプロップ:削除中はボタンを無効化
  • 削除ボタンの表示:onDeleteが渡されている場合のみ表示

5-5. ツイート一覧を更新

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;
    onDelete?: (tweetId: string) => void;
    deletingTweetId?: string;
}

export default function TweetList({
    tweets,
    username,
    isLoading,
    isRefreshing,
    onRefresh,
    onDelete,
    deletingTweetId,
}: 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}
                            onDelete={onDelete}
                            isDeleting={deletingTweetId === tweet.id}
                        />
                    ))}
                </div>
            )}
        </div>
    );
}

追加した機能:

  • onDeleteプロップ:削除処理を親コンポーネントに委譲
  • deletingTweetId:現在削除中のツイートID

5-6. メインページを更新

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, createTweet, deleteTweet } from "@/lib/twitter-api";
import TweetList from "@/components/TweetList";
import TweetComposer from "@/components/TweetComposer";

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 [deletingTweetId, setDeletingTweetId] = useState<string | undefined>();
    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);
    };

    // ツイートを投稿
    const handleCreateTweet = async (text: string) => {
        if (!session) {
            throw new Error("認証が必要です");
        }

        await createTweet(text);
    };

    // ツイート投稿後
    const handleTweetCreated = () => {
        handleRefresh();
    };

    // ツイートを削除
    const handleDeleteTweet = async (tweetId: string) => {
        if (!session) return;

        setDeletingTweetId(tweetId);
        try {
            await deleteTweet(tweetId);

            // ローカルの状態から削除
            setTweets((prev) => prev.filter((t) => t.id !== tweetId));
            setError(null);
        } catch (err) {
            console.error("Failed to delete tweet:", err);
            setError("投稿の削除に失敗しました");
        } finally {
            setDeletingTweetId(undefined);
        }
    };

    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>
                )}

                {/* ツイート作成 */}
                <TweetComposer
                    onTweetCreated={handleTweetCreated}
                    onCreateTweet={handleCreateTweet}
                />

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

追加した機能:

ツイート投稿

const handleCreateTweet = async (text: string) => {
    if (!session) {
        throw new Error("認証が必要です");
    }

    await createTweet(text);
};

const handleTweetCreated = () => {
    handleRefresh();  // 投稿後、画面を更新
};

ツイート削除

const handleDeleteTweet = async (tweetId: string) => {
    if (!session) return;

    setDeletingTweetId(tweetId);
    try {
        await deleteTweet(tweetId);

        // ローカルの状態から削除(即座に画面から消える)
        setTweets((prev) => prev.filter((t) => t.id !== tweetId));
        setError(null);
    } catch (err) {
        console.error("Failed to delete tweet:", err);
        setError("投稿の削除に失敗しました");
    } finally {
        setDeletingTweetId(undefined);
    }
};

楽観的UI更新:

削除APIのレスポンスを待たずに、画面からツイートを消します。これにより、ユーザーはすぐに変化を感じられます。


6. 動作確認

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

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

npm run dev

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

6-2. ツイートを投稿してみる

手順:

  1. テキストボックスに「テスト投稿」と入力
  2. 残り文字数が表示される(270 / 280)
  3. 「投稿する」ボタンをクリック
  4. プレビュー画面が表示される
  5. 「投稿する」をクリック
  6. 画面が更新され、新しいツイートが一番上に表示される!

成功です!

6-3. ツイートを削除してみる

手順:

  1. ツイートの右上にある「🗑️ 削除」ボタンをクリック
  2. 即座に画面からツイートが消える!

これで完成です!


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

エラー1: 403 Forbidden(ツイート投稿時)

症状:

ツイート投稿時に「Failed to create tweet」というエラーが表示される。

原因:

tweet.writeスコープが含まれていない。

解決方法:

  1. lib/auth.tsのスコープを確認:
   scope: "users.read tweet.read tweet.write offline.access"
  1. tweet.writeが含まれているか確認

  2. Twitter Developer Portalで「App permissions」が「Read and Write」になっているか確認

  3. サインアウトして再サインイン(重要!)

デバッグ方法:

ターミナルのログでスコープを確認:

[auth][debug] token: {
  scope: "users.read tweet.read tweet.write offline.access"
}

tweet.writeが含まれていない場合、これが原因です。

エラー2: 「Tweet text is required」

症状:

空のツイートを投稿しようとするとエラーが表示される。

原因:

バリデーションが正しく機能している(これは正常な動作です)。

解決方法:

テキストを入力してから投稿してください。

エラー3: 削除ボタンが表示されない

症状:

ツイートに削除ボタンが表示されない。

原因:

TweetListコンポーネントにonDeleteプロップが渡されていない。

解決方法:

app/page.tsxを確認:

<TweetList
    tweets={tweets}
    username={userInfo?.username || ""}
    isLoading={isLoading}
    isRefreshing={isRefreshing}
    onRefresh={handleRefresh}
    onDelete={handleDeleteTweet}  // ← これが必要
    deletingTweetId={deletingTweetId}
/>

エラー4: プレビューが表示されない

症状:

「投稿する」ボタンをクリックしても、プレビューが表示されない。

原因:

TweetPreviewコンポーネントがインポートされていない。

解決方法:

components/TweetComposer.tsxの冒頭を確認:

import TweetPreview from "./TweetPreview";

エラー5: 投稿後に画面が更新されない

症状:

ツイートを投稿しても、一覧に表示されない。

原因:

handleTweetCreatedhandleRefreshが呼ばれていない。

解決方法:

app/page.tsxを確認:

const handleTweetCreated = () => {
    handleRefresh();  // ← これが必要
};

8. さらに改善するアイデア

8-1. 実装できる追加機能

このアプリをさらに改善するアイデア:

機能説明難易度
画像付きツイートTwitter APIのメディアアップロード機能を使用
スレッド機能複数のツイートを連続投稿
下書き保存ローカルストレージに下書きを保存
ダークモードTailwind CSSのダークモードを実装
キャッシュ機能ローカルストレージにツイートをキャッシュ
予約投稿指定時刻に自動投稿
ツイート編集Twitter APIのツイート編集機能を使用
リツイート機能自分のツイートをリツイート

8-2. 本番環境へのデプロイ

Vercelにデプロイする手順:

  1. GitHubにリポジトリを作成
  2. コードをプッシュ
  3. Vercelにログイン
  4. 「New Project」をクリック
  5. GitHubリポジトリを選択
  6. 環境変数を設定:
    • TWITTER_CLIENT_ID
    • TWITTER_CLIENT_SECRET
    • NEXTAUTH_URL(本番URL)
    • NEXTAUTH_SECRET
  7. デプロイ

Twitter Developer Portalの設定も忘れずに:

  • Callback URLを本番URLに変更(例:https://your-app.vercel.app/api/auth/callback/twitter
  • Website URLを本番URLに変更

まとめ

このシリーズで学んだこと

第1部:認証編

✅ Twitter APIとは何か
✅ OAuth認証の仕組み
✅ NextAuth.jsでの認証実装
✅ 環境変数の設定

第2部:ツイート表示編

✅ Twitter APIからデータを取得する方法
✅ APIルートの作成
✅ UIコンポーネントの作成
✅ エラーハンドリング

第3部:投稿・削除編

✅ ツイート投稿APIの実装
✅ ツイート削除APIの実装
✅ バリデーション
✅ プレビュー機能
✅ 楽観的UI更新
✅ 403 Forbiddenエラーの解決

完成したアプリの機能

✅ X(Twitter)アカウントでログイン
✅ 自分のツイートを表示
✅ 新しいツイートを投稿
✅ ツイートを削除
✅ リアルタイムで画面が更新される
✅ 文字数カウント
✅ プレビュー機能

お疲れさまでした!完成です!🎉


参考資料

公式ドキュメント

React関連

UI/UX関連

デプロイ関連

トラブルシューティング

コミュニティ


最後に

3部作を通して、Next.jsとTwitter API v2を使った完全なWebアプリケーションを作成しました。

重要なポイント:

  1. セキュリティ:アクセストークンはサーバーサイドでのみ使用
  2. エラーハンドリング:詳細なログでデバッグを容易に
  3. UX:ローディング状態、プレビュー、楽観的UI更新
  4. バリデーション:クライアント側とサーバー側の両方で検証

次のステップ:

  • 本番環境へのデプロイ
  • 追加機能の実装(画像投稿、スレッド、下書きなど)
  • パフォーマンス最適化
  • テストの追加

このアプリを自分好みにカスタマイズして、楽しんでください!

質問があれば、Twitter APIのコミュニティやStack Overflowで聞いてみましょう。

Happy Coding! 🚀