❄️

Next.js独学で簡易的なTwitter作ってみた(AppRouter)

2024/01/24に公開

はじめに

今年で2年目を迎える新人エンジニアです。Reactは元から学びたいと思っていましたが、仕事ではPHPやRubyなどバックエンド系の言語を使うことが多く独学でやってみるか....という成り行きで作り始めました。所々おかしな点もあるかと思いますがご了承ください..。

制作物概要


UIに全くセンスがなかったためTwitterの配置を真似シンプルなデザインとなりました。

【機能一覧】

・ログイン(GitHubとGoogleのみ)
・投稿
・いいねボタン
・コメント
・ハッシュタグ(#)
・トレンド一覧
・検索機能(記事とユーザー)
・フォロー
・プロフィール編集
・自身のいいね/フォロー/フォロワー一覧
・ダイレクトメッセージ(Swrのリアルタイム)

開発環境

【フレームワーク】

・Next.js 14(AppRouter)
・Tailwindcss

【使用ライブラリ】

・NextAuth
・Material UI
・Prisma
・React-hot-toast
・Zod
・useSWR

※DBはSupabaseを使用しVercelでデプロイしました

詳細

ログイン認証

ログイン認証にはNext.jsの公式チュートリアルにもあり比較的簡単に実装できそうだったためNextAuth.jsとPrismaを使用しました。過去にもNextAuth+Prisma+Supabaseで簡単に実装できる記事をあげているので良ければ見てみて下さい
https://zenn.dev/tarako314/articles/3beb458d96d6ba

セッションの取得とログイン状態の判別

セッションの取得にはuseContextを使用されているのでpropsで渡しまくるバケツリレーにならず、どこからでもセッションが取得できるのが使いやすいイメージでした。またログイン状態の判別はonUnauthenticated関数が行なってくれるので楽でした

const { data: session, status } = useSession({
    required: true,
    onUnauthenticated() {
      // The user is not authenticated, handle it here.
      redirect("/login")
    },
  })
セッションをどこからでも取り出せるようchildrenをラップする

SessionProviderはuseContextのためクライアントコンポーネントでしか扱えません
一度クライアントコンポ―ネントで定義しlayouyt.tsのchildrenをラップする必要があリます

NextAuth.tsx
'use client'

import { SessionProvider } from 'next-auth/react'
import { ReactNode } from 'react'

const NextAuthProvider = ({ children }: { children: ReactNode }) => {
  return <SessionProvider>{children}</SessionProvider>
}

export default NextAuthProvider
layout.ts
import type { Metadata } from 'next'
import NextAuthProvider from '@/providers/NextAuth'
import './globals.css'

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className="flex items-center flex-col bg-gray-100 text-sm"> 
        <NextAuthProvider>{children}</NextAuthProvider>
      </body>
    </html>
  )
}

投稿Id/ユーザーIdページの識別

投稿/ユーザー詳細ページはDynamic Routesを使用しています。
ルート直下に[id]のようなディレクトリを作成しpage.tsxファイルを下記のようにします

/[id]/page.tsx
const IndexPage = ({ params }: { params: { id: string } }) => {
return ()
}

このように書く事でUrlのIdがparamsから取得できるようになり、投稿Id/ユーザーIdページの識別が容易になります。又、IdがDBに存在しなかった場合は404Errorに飛ばしたいためFetchId関数を作り分岐させる事にしました。(これが正しいやり方かはわかりません...)

const [checked, setChecked] = useState("");

  useEffect(() => {
    FetchId(data.id)
    getPostDetail(data.id)
  },[])

  if(checked === null){
    return (
      <Page404 />
    )
  }
  const FetchId = async(id: any) => {
    const response = await fetch(`/api/getPostId?Id=${id}`);
    const data = await response.json();
    setChecked(data.checkId)
  }

ハッシュタグ/トレンド

元々Twitterを真似て作っていたので、#ハッシュタグのように文字色が青く遷移出来るようにしたかったですが、TextArea内の入力された文字を関数で判別し返す方法ではDOM操作が上手くいかず以下のような機能に変更しました。

トレンド取得APIの処理

一日単位でトレンドを出力し、各表示トレンドをクリックするとキーワード検索され一覧が表示される

ここの処理は正規表現を今まで使ったこなかったので少し時間がかかった気がします
#(半角)sampleと#(全角)sampleを同じものとして取得したり、取得した上位10個のハッシュタグと数を配列で返しています。今は検索投稿数が少ないので0.25sくらいで取得で来ていますが、件数が増えたらレスポンス時間がどんどん増えていかないか不安です....

route.ts
import { NextRequest, NextResponse } from "next/server";
import prisma from "@/lib/Prisma";

export async function GET(req: NextRequest, res: NextResponse) {
    try {
        const todayStart = new Date();
        todayStart.setHours(todayStart.getHours() - 15);

        const posts = await prisma.post.findMany({
            where: {
                createdAt: {
                    gte: todayStart,
                },
                OR: [
                    { content: { contains: "#" } },
                    { content: { contains: "#" } }
                ],
            },
            select: { content: true }
        });

        // ハッシュタグを抽出してカウントする
        const hashtagCounts: Record<string, number> = posts.reduce((acc, post) => {
            // 正規表現を使用してハッシュタグを抽出('#' と '#' の両方に対応)
            const hashtags = post.content.match(/[##][\w----]+/g) || [];
            hashtags.forEach(tag => {
                // ハッシュタグを正規化(小文字に変換し、全角の '#' を半角に変換)
                const normalizedTag = tag.toLowerCase().replace(//, '#');
                acc[normalizedTag] = (acc[normalizedTag] || 0) + 1;
            });
            return acc;
        }, {} as Record<string, number>);

        // ハッシュタグを多い順にソートして上位10個を取得し、それぞれのカウントと共に
        const Trends = Object.entries(hashtagCounts)
        .sort((a, b) => b[1] - a[1])
        .slice(0, 10)
        .map(entry => ({ tag: entry[0], count: entry[1] }));     
            
        return NextResponse.json(
            { Trends, message: "Success" },
            { status: 200 },
        );
    } catch (err) {
        return NextResponse.json({ err, message: "Error" }, { status: 500 });
    } finally {
        await prisma.$disconnect();
    }
}

ダイレクトメッセージ


こちらのメッセージ機能はvercelのuseSWRというデータフェッチライブラリを使用しメッセージをリアルタイムで取得し表示しています。検索している中で他にもリアルタイムにデータを取得できる方法はありそうでしたが、友人から「swrっていうの使いやすいよ〜」と教えてもらい実際短いコードでリアルタイムが実現できたのでとても楽でした
https://swr.vercel.app/ja

反省点

【いいねボタンTAP時の反応の遅さ】
・DBに格納される前にUI表示だけ数字を加算し、DBの格納が失敗したときに戻すような処理を入れたがったが間に合わなかった。参考にさせて頂いた記事↓
https://zenn.dev/funteractiveinc/articles/optimistic-update

【セッション以外はpropsで渡していたため2,3回のバケツリレーがあった】
・useContextや状態管理ライブラリなどを使いデータの受け渡しをスムーズに行うべきだった

【同じ処理のコンポーネント化】
・基本的には同じ処理を行なっている所はコンポーネント化したがまだリファクタリングできる箇所が多々あったり、UIコンポーネントと処理用のコンポーネントをフォルダごとに分けるべきだった

【Google,Github以外にもカスタムメールアドレスでSingIn,LogInの実装】
・ソーシャルログインのみしか実装していなかったためメールアドレスでのログインを可能にする。又、apiの通信時AccessTakenの確認をしていなかったためapiのurlを知っていると誰でも、一部情報が見れてしまう(一応ユーザーネームなど見れてしまっても大丈夫な情報ではある)

【useStateを至る所で必要以上に使用していた】
・fetchして取得したデータをuseStateで管理していたが、一つのデータを単一のstateにに格納していたため必要以上に使用してしまっていた。類似したデータをオブジェクトとしてまとめていれば簡潔で見やすいコードになっていた可能性があった

今回はNext.jsを初めて使ったのでHooksの使い方・AppRouterなどを理解するのに時間がかかってしまいました。又制作期間を決めて開発していたので調べ調べで追加したい機能がすべて実装できずに終わってしまいました。まだまだ勉強不足な点が多く公式ドキュメントをしっかり読み、理解したことを記事にまとめたりなどこれからも精進していきたいと思います。

最後まで読んでいただきありがとうございました

Discussion