💠

Next.js App Router で実装!技術スタック共有っぽいサンプルアプリ

に公開

先月執筆した 「Next.js App Router で実装!フリマっぽいサンプルアプリ」 という記事に続き、たまたま機会があったので Next.js App Router で実装シリーズの第二弾として、技術スタック共有サイトっぽいサンプルアプリを作ってみました。

https://zenn.dev/t_hayashi/articles/e7917167b68a6b

前回の記事では、ディレクトリ構成やレンダリングモデルなどの基本的なところを手探りで書いていました。今回の記事では、ディレクトリ構成など踏襲しつつ下記の部分をアップデートとして取り組んでみました。

  • ORM を用いた DB アクセス
  • 簡易的なテスト
  • Next.js 特有のモーダル実装
  • 認証認可
  • OpenTelemetry による計装

今回は「Next.js でフルスタックに実装する際の技術スタックは何を使う?」というのをテーマに実装してみました。一ヶ月間の隙間時間で実装したにしては色々な要素を詰め込めて、自分なりに最低限こういった周辺ツールを使えると良さそうという感覚を得ることができました。

他にも良い選択肢があればぜひコメントで教えていただけると幸いです。

この記事のターゲット

  • Next.js 15 の App Router でのサンプルアプリケーションに興味がある方

構成・リポジトリ

今回は、下記のような構成で Next.js 中心に連携させてみました。データベースはお手軽さを選んで、SQLite としています。

拙いコードですが、書いたものは下記のリポジトリにまとめています。アプリケーションの概要はこちらにあります。

https://github.com/hayashit6239/nextjs-app-sample-techhub/tree/main

実装まとめ

主要な環境・パッケージ・バージョン

詳細は package.json 等を見ていただければと思いますが、主要どころは抜き出しておきます。

  • next: 15.2.3
  • react:19.0.0
  • tailwindow:4.0.0
  • yarn:1.22.19
  • prisma:6.4.1
  • bun:1.2.4

ディレクトリ構成

ディレクトリ構造はこんな感じです。前回の「Container 1st な設計」を踏襲しつつ、下記の記事も参考にさせていただいています。

https://zenn.dev/sonicmoov/articles/e5ce7fb6d42267

app
├── projects
│   ├── page.tsx
│   ├── error.tsx
│   ├── loading.tsx
│   ├── _containers
│   │   ├── project-list
│   │   │   ├── index.tsx
│   │   │   ├── container.tsx
│   │   │   └── presentation.tsx
│   ├── _utils
│   │   └── fetcher.ts
│   ├── _actions
│   │   └── mutater.ts
│   └── [id]
└──  ...
features
├── projects
│   └── components
│       ├── project-adoption-modal.tsx
│       └──  ...
└──  ...
test
├── projects.test.ts
└──  ...

Prisma による DB アクセス

DB は SQLite として、ORM は Prisma を使ってみました。Node.js と TypeScript のための ORM としては最近では Prisma が人気とは聞いていたので、この機会に使ってみたく。

一通りの利用方法としては下記の本を参考にさせていただきました。

https://zenn.dev/hayato94087/books/e9c2721ff22ac7

外部参照しているテーブルの情報を結合して取得のが容易だったのが印象的でした。

こちらでプロジェクトで採用されている技術を取得する操作をしているのですが、中間テーブルである adoptions テーブルから外部参照されている projects テーブルや techtopics テーブルを include で指定するだけで良いのが好みでした。

schema.prisma
model Adoption {
  id          Int       @id @default(autoincrement())
  projectId   Int
  techtopicId Int
  version     String
  purpose     String
  project     Project   @relation(fields: [projectId], references: [id])
  techtopic   Techtopic @relation(fields: [techtopicId], references: [id])

  @@map("adoptions")
}
app/projects/_utils/fetcher.ts
...
async function _fetchAdoptions(): Promise<Adoption[]> {
    return await prisma
        .adoption
        .findMany({
            include: {
                project: {
                    include: {
                        department: true,
                    }
                },  
                techtopic: {
                    include: {
                        techcategory: true,
                    }
                }
            }   
        })
        .then((adopts) => {
            return adopts.map((adopt) => {
                return {
                    id: adopt.id,
                    projectId: adopt.project.id,
                    techtopic: {
                        id: adopt.techtopic.id,
                        name: adopt.techtopic.name,
                        techcategoryName: adopt.techtopic.techcategory.name,
                    },
                    version: adopt.version,
                    purpose: adopt.purpose,
                }
            })
        })
}

✨ Tips!:テーブル名の指定

schema.prisma ファイルでのデータモデル定義は単数形で行うのが一般的なようです。ただ、このまま npx prisma migrate コマンドで DB に反映させると単数形でテーブルが作成されてしまいます。

テーブルは複数形で作成したかったので、この場合はデータモデルの定義の中で @@map("adoptions") とマッピングすることで指定したテーブル名で作成することができました。

Bun Test によるテスト

テストツールとして、Bun という JavaScript と TypeScript のアプリのためのオールインワンツールキットに同梱されているテストランナーを使ってみました。公式ドキュメントの中で「JavaScript ランタイム、起動時間とメモリ使用量を劇的に削減するのが特徴」と書かれていることから軽量かつ高速な JavaScript ランタイムと押し出している感じがしました。

https://bun.sh/docs

最低限のテスト方針として、Server Components で実行されるデータ操作関数に対して行っています。下記の本の内容にある Server Components へのテストを考えたの参考としています。

https://zenn.dev/akfm/books/nextjs-basic-principle/viewer/part_2_container_presentational_pattern

test/projects.test.ts
import { getProjects } from "@/app/projects/_utils/fetcher";
...
describe("正常系: ProjectListContainer で DB からプロジェクト一覧取得成功テスト", () => {
    test("ProjectListPresentational にプロジェクト一覧を渡すことができる", async () => {
        const res = await getProjects();

        expect(res.ok).toBe(true);

        if (!res.ok) {
            return;
        }

        const projects = res.value;

        expect(projects.length).toBe(6);
        expect(projects.filter((project) => project.id === 1)[0].techtopics?.length).toBe(6);
        expect(Object.keys(projects[0]).length).toBe(7);
    })
});
...

上記でテストしている関数が下記になります。今回はデータフェッチを担う関数は各セグメントの _utils/fetcher.ts にまとめています。また、実際の Prisma によるデータ操作は _fetchHoge 関数に切り出しています。

app/projects/_utils/fetcher.ts
...
export async function getProjects(): Promise<Result<ResProject[], string>> {
    try {
        const projects = await _fetchProjects();

        const adoptions = await _fetchAdoptions();

        const projectsWithTechtopics = projects.map((project) => {
            return {
                id: project.id,
                name: project.name,
                description: project.description,
                representativeName: project.representativeName,
                representativeEmail: project.representativeEmail,
                departmentName: project.departmentName,
                techtopics: adoptions
                    .filter((adoption) => adoption.projectId === project.id)
                    .map((adoption) => {
                        return {
                            id: adoption.techtopic.id,
                            name: adoption.techtopic.name,
                            techcategoryName: adoption.techtopic.techcategoryName,
                            version: adoption.version,
                            purpose: adoption.purpose,
                        }
                    })
            } as ResProject;
        })
        return { ok: true, value: projectsWithTechtopics };
    } catch (e) {
        if (e instanceof Error) {
            return { ok: false, error: e.message };
        }
        throw e; 
    }
}
...

元々は REST API を作る練習も兼ねていたのでバックエンドは Route Handlers で実装していました。その時は下記を参考にさせていただきテストを実装していました。

https://blog.silolab.net/article/testing-nextjs-route-handler-with-next-request

ある程度感覚が掴めたので Route Handlers から Server Components に移行を実施しました。その際にテストがあらかじめ実装できていたので、挙動を担保できてスムーズに移行できテストのありがたみを感じることができました。

✨ Tips!:テストのための seed 投入

途中から気づいたのですが、SQLite をアプリケーションを動かすためとテストのためと両方に使っているため DB の内容が変わるとテストのために初期の DB に合わせて書いたテストがこけるようになっていました。

これを解消するには、「アプリケーションを動かす用とテスト用で SQLite の DB ファイルを分ける」「テストのたびに seed を投入する」という二通りがありあそうです。

今回は雑に、package.json ファイルで下記のように設定することでテストコマンドである yarn test を実行する度に npx prisma migrate reset --force コマンドが実行されて seed 投入を行い、想定したデータでテストを可能としました。(今思えば、DB ファイルを分けて Prisma のクライアントを分けるとかでもっとスマートに対応できたかもしれません。。)

package.json
{
  "name": "tech-hub",
  "version": "0.1.0",
  "private": true,
  "tyepe": "module",
  "scripts": {
    "dev": "next dev --turbopack",
    ...
    "test": "npx prisma migrate reset --force && bun test"
  },
  ...
}

Parallel Routes & Intercepting Routes によるモーダル実装

こちらのようにモーダルの実装は Mantine などの UI コンポーネントを利用しようと思ったのですが、実際に利用してみると下記のように layout.tsx でスタイルした padding が消える事象にあたりました。

そこで調べてみると、Parallel RoutesIntercepting Routes を組み合わせて実現する Next.js 特有のモーダル実装が可能なことがわかったので今回試してみました。

https://nextjs.org/docs/app/building-your-application/routing/intercepting-routes#modals

ドキュメントの中で Parallel Routes と Intercepting Routes によるモーダル実装は下記のような機能を提供するとのことです。

Intercepting Routes は、Parallel Routes と組み合わせて、モーダルを作成するために使用できます。これにより、モーダルを構築する際の一般的な課題を解決できます。例えば:

・モーダルのコンテンツをURLを通じて共有可能にする。
・ページがリフレッシュされたときに、モーダルを閉じるのではなく、コンテキストを維持する。
・戻る操作で、前のルートに戻るのではなく、モーダルを閉じる。
・進む操作で、モーダルを再度開く。

ユーザーがクライアントサイドナビゲーションを使用してギャラリーから写真モーダルを開いたり、共有可能なURLから直接写真ページに移動したりできる、次のUIパターンを考えてみましょう。
公式ドキュメント - Intercepting Routes

ざっくりいうと、Parallel Routes によって同一レイアウト内で複数のページの同時表示が可能となり、Intercepting Routes によって特定のナビゲーションを横取りしてモーダルを表示しています。

具体的には、projects/[id]/edit/create というページに対応する app/projects/[id]/edit/create/page.tsx ファイルを作成しておき、そのページに遷移した時に横取りする app/projects/[id]/edit/@modal/(.)create/page.tsx というモーダルを作成することでページ遷移によるモーダル表示を実現しています。

こちらの記事を参考にさせていただきつつ、下記のように実装しています。

https://github.com/hayashit6239/nextjs-app-sample-techhub/tree/main/src/app/projects/[id]/edit

🌧️ Problem:Server Functions 実行後のページ遷移

うまく実装ができなった点として、Server Functions を実行にした後に元のページ戻り実行結果を反映させるところがあります。

下記のように useActionState を使って実行後の state を取得するようにしています。今回は最低限として、postAdoption 関数の返り値に status を含めておき、TechtopicCreateEditorPresentational コンポーネントの中で status が成功になった場合に router.back() によってブラウザバックをすることでモーダルを消す挙動を再現しています。

これが上記にある「・戻る操作で、前のルートに戻るのではなく、モーダルを閉じる。」という挙動になります。

app/projects/[id]/edit/create/_containers/techtopic-create-editor/presentational.tsx
...
export function TechtopicCreateEditorPresentational(props: Props) {
    const { projectId, techtopicCategorTopics } = props;

    const [state, formAction] = useActionState(
        postAdoption,
        {
            projectId: projectId,
            techtopicId: 0,
            version: "",
            purpose: "",
        }
    );

    const router = useRouter();
  ...

    useEffect(() => {
        if (state.status === FormStatus.SUCCESS) {
            router.back();
        }
    }, [router, state.status]);
...
app/projects/[id]/edit/create/_actions/mutater.ts
...
export async function postAdoption(prevState: PostAdoptionFormState, formData: FormData): Promise<PostAdoptionFormState> {
    const projectId = formData.get("projectId") as string;
    const techtopicId = formData.get("techtopicId") as string;
    const version = formData.get("version") as string;
    const purpose = formData.get("purpose") as string;

    const validatedFormData = PostAdoptionFormSchema.safeParse({
        projectId: Number(projectId),
        techtopicId: Number(techtopicId),
        version,
        purpose,
    });


    if (!validatedFormData.success) {
        return {
            status: FormStatus.FAILED,
            projectId: Number(projectId),
            techtopicId: Number(techtopicId),
            version,
            purpose,
            zodErrors: validatedFormData.error.flatten().fieldErrors as ZodErrors,
        }
    }

    await _mutateCreateAdoption(validatedFormData.data);

    return {
        status: FormStatus.SUCCESS,
        projectId: Number(projectId),
        techtopicId: Number(techtopicId),
        version,
        purpose,
    }
}
...

理想としては、モーダルを閉じた後に最新の情報が反映されていることなのですが、redirect 関数などやってみましたがうまくいきませんでした。

ご存知の方いたら、ぜひ教えてください。

認証認可 & セッション管理

こちらでは、認証認可周りとして Next.js の公式ドキュメントに沿って実装しました。今まであまりしっかり実装したことがなかったので、勉強にもなったので非常によかったです。

https://nextjs.org/docs/app/building-your-application/authentication

ログインボタンをクリックすると、Server Functions が実行されて zod による入力値のバリデーションが行われます。バリデーションで問題がなければ、あらかじめ登録されたユーザー情報と一致するかの検証が行われます。

ここで一致するユーザー情報があれば、セッション管理を行います。具体的には、createSession 関数によって JWT を作成して cookie に登録します。

app/api/users/login/route.ts
// Server Functions 移行前
...
export async function POST(request: NextRequest) {
    const body = await request.json();

    const user = await _fetchUser(body.username);

    if(!user) {
        return NextResponse.json(
            { message: "User not found" },
            {
                status: 404,
                headers: corsHeaders
            }
        );
    }

    const passwordMatch = await compare(body.password, user.password);

    if(!passwordMatch) {
        return NextResponse.json(
            { message: "Password doesn't match" },
            {
                status: 401,
                headers: corsHeaders
            }
        );
    }

    const secretKey = process.env.JWT_SECRET_KEY;

    if(!secretKey) {
        return NextResponse.json(
            { message: "Internal Server Error" },
            {
                status: 500,
                headers: corsHeaders
            }
        );
    }

    await createSession(user.id, user.name, user.username, secretKey);

    return NextResponse.json(
        user,
        {
            status: 200,
            headers: corsHeaders
        }
    );
}
...
lib/session.ts
...
export async function createSession(userId: number, name: string, username: string, secretKey: string) {
    const expiredAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    const session = await encrypt(
        {
            sub: String(userId),
            name,
            username,
        } as SessionPayload,
        new TextEncoder().encode(secretKey)
    );
    const cookieStore = await cookies();
   
    cookieStore.set("session", session, {
        httpOnly: true,
        secure: true,
        expires: expiredAt,
        sameSite: "lax",
        path: "/",
    })
}
...

これで認証とセッション管理は実装できました。最後に認可ですが、middleware.ts でリクエストごとに確認取るのかなーと思いつつ、時間切れで未実装です。

関連しているコードはこちらになります。
https://github.com/hayashit6239/nextjs-app-sample-techhub/tree/main/src/app/login

OpenTelemetry での計装

オブザーバビリティとして OpenTelemetry による計装を行い Honeycomb にトレースを送って可視化してみました。

https://nextjs.org/docs/app/building-your-application/optimizing/open-telemetry

シンプルに instrumentation.node.tsOTLPExporter を実装して Prisma に関する計装をライブラリ計装で行っています。

instrumetation.node.ts
// Imports
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"
import { NodeSDK } from "@opentelemetry/sdk-node"
import { PrismaInstrumentation } from "@prisma/instrumentation"
import { Resource } from "@opentelemetry/resources"
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node"
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'


// Configure the OTLP trace exporter
const traceExporter = new OTLPTraceExporter({
  url: "https://api.honeycomb.io/v1/traces", // Replace with your collector's endpoint
  headers: {
    "x-honeycomb-team": `${process.env.HONEYCOMB_API_KEY}`, // Replace with your Honeycomb API key or collector auth header
  },
})

// Initialize the NodeSDK
const sdk = new NodeSDK({
  resource: new Resource({
      [ATTR_SERVICE_NAME]: "techhub", // Replace with your service name
  }),
  spanProcessor: new SimpleSpanProcessor(traceExporter),
  instrumentations: [
    new PrismaInstrumentation({
      middleware: true, // Enable middleware tracing if needed
    }),
    getNodeAutoInstrumentations()
  ],
})

// Start the SDK
sdk.start()

Header に x-honeycomb-team というキーに作成した環境の ID を値として渡してあげると、Honeycomb にテレメトリを送ることができます。

.env ファイルには HONEYCOMB_API_KEY が空で設定してあるため、必要に応じて設定してください。

今回は、Prisma に関するライブラリ計装を実装しているので、Honeycomb 上ではリクエストやナビゲーションが発生した際の Prisma の一連のデータ操作をトレースで追うことができました。

Cloud Run にデプロイ

個人的に Google Cloud が好きということで Cloud Run でホスティングしてみました。Next.js アプリケーションの Cloud Run デプロイは割と記事が多かったので、下記のハマりどころを除いてそこまで難しさはありませんでした。

https://zenn.dev/google_cloud_jp/articles/nextjs-on-cloudrun

雑にこんな Dockerfile を書いています。

Dockerfile
# Use an official Node runtime as the parent image
FROM node:20.11.0-alpine as builder

ENV NODE_ENV=development

# Set the working directory in the container to /app
WORKDIR /app

# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
COPY postcss.config.mjs ./
COPY ./yarn.lock ./

# Add dependencies to get Bun working on Alpine
RUN apk --no-cache add ca-certificates wget
RUN wget https://raw.githubusercontent.com/athalonis/docker-alpine-rpi-glibc-builder/master/glibc-2.26-r1.apk
RUN apk add --allow-untrusted --force-overwrite glibc-2.26-r1.apk
RUN rm glibc-2.26-r1.apk
# Install Bun
RUN npm install -g bun

# Install any needed packages specified in package.json
RUN yarn install

# Copy the rest of the application code to the working directory
COPY . .

RUN npx prisma generate

# Build the Next.js application
RUN yarn build

FROM node:20.11.0-alpine as runner
ENV NODE_ENV=production

WORKDIR /app

COPY --from=builder /app/next.config.ts ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/prisma/dev.db ./node_modules/.prisma/dev.db

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV DATABASE_URL="file:/app/node_modules/.prisma/dev.db"

CMD ["node", "server.js"]

✨ Tips!:CPU アーキテクチャの不一致

ローカルで開発していたたため、最初はローカルでビルドして Cloud Run にデプロイするという形をとっていました。

いつも通り、docker build コマンドでビルドして Cloud Run にデプロイすると terminated: Application failed to start: failed to load /usr/local/bin/docker-entrypoint.sh: exec format error というエラーが。

これは CPU アーキテクチャの不一致によるものだったので、ビルド時に --platform linux/amd64 をつける必要がありました。

✨ Tips!:モーダル表示でクラッシュ

デプロイはできたものの Parallel Routes と Intercepting Routes で実装したモーダルを表示するページに遷移するとアプリケーションがクラッシュしました。

正確には Parallel Routes が Cloud Run 上で動かないようで下記の Issue を参考に next.config.ts ファイルでファイルの参照先を変更することで解消しました。

https://github.com/vercel/next.js/issues/71626

✨ Tips!:SQLite の DB ファイルを参照ミス

これは SQLite 特有の話なのですが、Cloud Run にデプロイ後に DB を参照できない事態に陥りました。

DB を参照するために Prisma のスキーマファイルである schema.prisma ファイルで下記のようにファイル URL を定義しています。

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}
...

Cloud Run にデプロイするためのコンテナは下記の Dockerfile で構築していました。Prisma に関する記述をしていませんでした。

Dockerfile
# Use an official Node runtime as the parent image
FROM node:20.11.0-alpine as builder

ENV NODE_ENV=development

# Set the working directory in the container to /app
WORKDIR /app

# Copy package.json and package-lock.json to the working directory
COPY package*.json ./
COPY postcss.config.mjs ./
COPY ./yarn.lock ./

# Add dependencies to get Bun working on Alpine
RUN apk --no-cache add ca-certificates wget
RUN wget https://raw.githubusercontent.com/athalonis/docker-alpine-rpi-glibc-builder/master/glibc-2.26-r1.apk
RUN apk add --allow-untrusted --force-overwrite glibc-2.26-r1.apk
RUN rm glibc-2.26-r1.apk
# Install Bun
RUN npm install -g bun

# Install any needed packages specified in package.json
RUN yarn install

# Copy the rest of the application code to the working directory
COPY . .

RUN npx prisma generate

# Build the Next.js application
RUN yarn build

FROM node:20.11.0-alpine as runner
ENV NODE_ENV=production

WORKDIR /app

COPY --from=builder /app/next.config.ts ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

マルチステージビルドで最終的には /app 配下に必要なファイルを動かしているため、開発環境での DB ファイル参照先を環境変数で設定したいのですが、それがコンテナ内では変わっていることが気づかずにデプロイしてしまいエラーを引いていました。

最終的に上記で書いたように Prisma ディレクトリの移行と環境変数による参照先の設定を記載したことで解消できました。

🌧️ Problem:PPR 設定後にログインモーダルが開かない

Partial Pre Rendering を設定してコンテナを立ち上げるとなぜかログインモーダルが開かなくなりました。

yarn dev ではうまく動くのですが、コンテナ立ち上げ時の node server.js では動かない感じです。

ここはあまり調査できていないので、一旦は PPR を切ることで正常に動作しています。

さいごに

前回のサンプルアプリの実装では基礎を学ぶために時間を割いていて、アディショナル部分に手を出せませんでした。今回はその分の時間を Prisma や認証認可やモーダル実装などアディショナル部分に時間を割くことができて、学びが広がりました。

まだまだ実装する中でわからないことがたくさんありますが、手探りながら楽しみながら App Router を追っていきたいとおもいます。

Google Cloud に加えて、フロントエンド周りでも発信を継続できたらと思っています。読んでいただきありがとうございました。

https://twitter.com/pHaya72

アプリ概要

最後に参考程度にアプリケーションの概要を説明しておきます。

プロダクトやチーム単位でプロジェクトを作成して採用している技術を登録することで、技術スタックを共有することを想定したサンプルアプリケーションになります。

参照元に戻る:構成・リポジトリ

プロジェクト一覧画面:/projects

登録済みのプロジェクトを一覧して閲覧できるプロジェクト一覧画面です。

プロジェクト詳細画面:/projects/[id]

登録済みのプロジェクトの詳細として紐づいている採用している技術を表示するプロジェクト詳細画面です。

参照元に戻る:Prisma による DB アクセス

プロジェクト編集画面:/projects/[id]/edit

ログインしたユーザーが所属しているプロジェクトであれば編集モードボタンが表示され、そのボタンをクリックすることで遷移するプロジェクト編集画面です。

技術追加モーダル:/projects/[id]/edit/create

ログインしたユーザーによる操作が可能なプロジェクトへの採用している技術の追加モーダルです。

参照元:Parallel Routes & Intercepting Routes によるモーダル実装

技術削除モーダル:/projects/[id]/edit/delete

ログインしたユーザーによる操作が可能なプロジェクトへの採用している技術の削除モーダルです。

ログインモーダル:/login

ログインのためのモーダルです。

参照元:認証認可 & セッション管理

参考

https://ui.shadcn.com/

https://dev.classmethod.jp/articles/next-js-parallel-routes-intercept-routes/

https://zenn.dev/axoloto210/articles/nextgram-modal

https://zenn.dev/mikakane/articles/tutorial_for_jwt

https://zenn.dev/kiriyama/articles/788af09432333c

Discussion