Next.jsとTwitter API v2で作る自分専用のX投稿アプリ【第3部:投稿・削除編】ついに完成!ツイートを投稿・削除しよう
第3部へようこそ!完成まであと少し!
第2部で自分のツイートが画面に表示されるようになりましたね。素晴らしい!
でも、「見るだけじゃつまらない...投稿もしたい!」と思っていませんか?
安心してください。この第3部で、ついにアプリが完成します。
この第3部のゴール
ツイートを投稿・削除できる完全なTwitterクライアントアプリの完成
この第3部を終えると:
✅ テキストボックスにツイートを入力して投稿できる
✅ 投稿前にプレビューで確認できる
✅ 自分のツイートを削除できる
✅ リアルタイムで画面が更新される
3部作の進捗状況
- ✅ 第1部(完了): ログイン機能
- ✅ 第2部(完了): ツイート表示機能
- 🔥 第3部(今回): ツイート投稿・削除機能 ← 完成!
目次
- ツイート投稿の仕組み
- ツイート投稿APIを作成する
- ツイート削除APIを作成する
- APIクライアントを更新する
- UIコンポーネントを作成する
- 動作確認
- よくあるエラーと解決方法
- さらに改善するアイデア
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.idで123456789を取得できる
これにより、どのツイートを削除するかを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" // 通常:グレー
}`}
残り文字数に応じて色が変わります。
投稿の流れ
- ユーザーが「投稿する」ボタンをクリック
handleSubmitでバリデーション- プレビューを表示
- 「投稿する」をクリック
handleConfirmPostで実際に投稿- 成功したら
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. ツイートを投稿してみる
手順:
- テキストボックスに「テスト投稿」と入力
- 残り文字数が表示される(270 / 280)
- 「投稿する」ボタンをクリック
- プレビュー画面が表示される
- 「投稿する」をクリック
- 画面が更新され、新しいツイートが一番上に表示される!
成功です!
6-3. ツイートを削除してみる
手順:
- ツイートの右上にある「🗑️ 削除」ボタンをクリック
- 即座に画面からツイートが消える!
これで完成です!
7. よくあるエラーと解決方法
エラー1: 403 Forbidden(ツイート投稿時)
症状:
ツイート投稿時に「Failed to create tweet」というエラーが表示される。
原因:
tweet.writeスコープが含まれていない。
解決方法:
lib/auth.tsのスコープを確認:
scope: "users.read tweet.read tweet.write offline.access"
-
tweet.writeが含まれているか確認 -
Twitter Developer Portalで「App permissions」が「Read and Write」になっているか確認
-
サインアウトして再サインイン(重要!)
デバッグ方法:
ターミナルのログでスコープを確認:
[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: 投稿後に画面が更新されない
症状:
ツイートを投稿しても、一覧に表示されない。
原因:
handleTweetCreatedでhandleRefreshが呼ばれていない。
解決方法:
app/page.tsxを確認:
const handleTweetCreated = () => {
handleRefresh(); // ← これが必要
};
8. さらに改善するアイデア
8-1. 実装できる追加機能
このアプリをさらに改善するアイデア:
| 機能 | 説明 | 難易度 |
|---|---|---|
| 画像付きツイート | Twitter APIのメディアアップロード機能を使用 | 高 |
| スレッド機能 | 複数のツイートを連続投稿 | 中 |
| 下書き保存 | ローカルストレージに下書きを保存 | 低 |
| ダークモード | Tailwind CSSのダークモードを実装 | 低 |
| キャッシュ機能 | ローカルストレージにツイートをキャッシュ | 中 |
| 予約投稿 | 指定時刻に自動投稿 | 高 |
| ツイート編集 | Twitter APIのツイート編集機能を使用 | 中 |
| リツイート機能 | 自分のツイートをリツイート | 中 |
8-2. 本番環境へのデプロイ
Vercelにデプロイする手順:
- GitHubにリポジトリを作成
- コードをプッシュ
- Vercelにログイン
- 「New Project」をクリック
- GitHubリポジトリを選択
- 環境変数を設定:
TWITTER_CLIENT_IDTWITTER_CLIENT_SECRETNEXTAUTH_URL(本番URL)NEXTAUTH_SECRET
- デプロイ
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)アカウントでログイン
✅ 自分のツイートを表示
✅ 新しいツイートを投稿
✅ ツイートを削除
✅ リアルタイムで画面が更新される
✅ 文字数カウント
✅ プレビュー機能
お疲れさまでした!完成です!🎉
参考資料
公式ドキュメント
- Twitter API v2 - Manage Tweets - ツイート投稿・削除
- Twitter API v2 - Rate Limits - レート制限の詳細
- NextAuth.js - Callbacks - コールバック関数の使い方
React関連
- React状態管理ガイド - useState、useEffectの使い方
- Reactのフォーム処理 - フォームの状態管理
UI/UX関連
- Tailwind CSS公式ドキュメント - CSSフレームワーク
- 楽観的UI更新のベストプラクティス - UXを向上させる手法
デプロイ関連
- Vercel公式ドキュメント - デプロイ方法
- Next.jsの環境変数設定 - 本番環境での環境変数
トラブルシューティング
- Twitter API エラーコード一覧 - エラーの意味
- OAuth 2.0トラブルシューティング - 認証エラーの解決
コミュニティ
- Twitter API Developers Community - 質問できるフォーラム
- Next.js Discord - Next.jsの公式Discord
- Stack Overflow - Twitter API v2のタグ
最後に
3部作を通して、Next.jsとTwitter API v2を使った完全なWebアプリケーションを作成しました。
重要なポイント:
- セキュリティ:アクセストークンはサーバーサイドでのみ使用
- エラーハンドリング:詳細なログでデバッグを容易に
- UX:ローディング状態、プレビュー、楽観的UI更新
- バリデーション:クライアント側とサーバー側の両方で検証
次のステップ:
- 本番環境へのデプロイ
- 追加機能の実装(画像投稿、スレッド、下書きなど)
- パフォーマンス最適化
- テストの追加
このアプリを自分好みにカスタマイズして、楽しんでください!
質問があれば、Twitter APIのコミュニティやStack Overflowで聞いてみましょう。
Happy Coding! 🚀