Next.js App Router で実装!技術スタック共有っぽいサンプルアプリ
先月執筆した 「Next.js App Router で実装!フリマっぽいサンプルアプリ」 という記事に続き、たまたま機会があったので Next.js App Router で実装シリーズの第二弾として、技術スタック共有サイトっぽいサンプルアプリを作ってみました。
前回の記事では、ディレクトリ構成やレンダリングモデルなどの基本的なところを手探りで書いていました。今回の記事では、ディレクトリ構成など踏襲しつつ下記の部分をアップデートとして取り組んでみました。
- ORM を用いた DB アクセス
- 簡易的なテスト
- Next.js 特有のモーダル実装
- 認証認可
- OpenTelemetry による計装
今回は「Next.js でフルスタックに実装する際の技術スタックは何を使う?」というのをテーマに実装してみました。一ヶ月間の隙間時間で実装したにしては色々な要素を詰め込めて、自分なりに最低限こういった周辺ツールを使えると良さそうという感覚を得ることができました。
他にも良い選択肢があればぜひコメントで教えていただけると幸いです。
この記事のターゲット
- Next.js 15 の App Router でのサンプルアプリケーションに興味がある方
構成・リポジトリ
今回は、下記のような構成で Next.js 中心に連携させてみました。データベースはお手軽さを選んで、SQLite としています。
拙いコードですが、書いたものは下記のリポジトリにまとめています。アプリケーションの概要はこちらにあります。
実装まとめ
主要な環境・パッケージ・バージョン
詳細は 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 な設計」を踏襲しつつ、下記の記事も参考にさせていただいています。
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 が人気とは聞いていたので、この機会に使ってみたく。
一通りの利用方法としては下記の本を参考にさせていただきました。
外部参照しているテーブルの情報を結合して取得のが容易だったのが印象的でした。
こちらでプロジェクトで採用されている技術を取得する操作をしているのですが、中間テーブルである adoptions テーブルから外部参照されている projects テーブルや techtopics テーブルを include で指定するだけで良いのが好みでした。
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")
}
...
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 ランタイムと押し出している感じがしました。
最低限のテスト方針として、Server Components で実行されるデータ操作関数に対して行っています。下記の本の内容にある Server Components へのテストを考えたの参考としています。
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
関数に切り出しています。
...
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 で実装していました。その時は下記を参考にさせていただきテストを実装していました。
ある程度感覚が掴めたので Route Handlers から Server Components に移行を実施しました。その際にテストがあらかじめ実装できていたので、挙動を担保できてスムーズに移行できテストのありがたみを感じることができました。
✨ Tips!:テストのための seed 投入
途中から気づいたのですが、SQLite をアプリケーションを動かすためとテストのためと両方に使っているため DB の内容が変わるとテストのために初期の DB に合わせて書いたテストがこけるようになっていました。
これを解消するには、「アプリケーションを動かす用とテスト用で SQLite の DB ファイルを分ける」「テストのたびに seed を投入する」という二通りがありあそうです。
今回は雑に、package.json
ファイルで下記のように設定することでテストコマンドである yarn test
を実行する度に npx prisma migrate reset --force
コマンドが実行されて seed 投入を行い、想定したデータでテストを可能としました。(今思えば、DB ファイルを分けて Prisma のクライアントを分けるとかでもっとスマートに対応できたかもしれません。。)
{
"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 Routes と Intercepting Routes を組み合わせて実現する Next.js 特有のモーダル実装が可能なことがわかったので今回試してみました。
ドキュメントの中で 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
というモーダルを作成することでページ遷移によるモーダル表示を実現しています。
こちらの記事を参考にさせていただきつつ、下記のように実装しています。
🌧️ Problem:Server Functions 実行後のページ遷移
うまく実装ができなった点として、Server Functions を実行にした後に元のページ戻り実行結果を反映させるところがあります。
下記のように useActionState
を使って実行後の state
を取得するようにしています。今回は最低限として、postAdoption
関数の返り値に status
を含めておき、TechtopicCreateEditorPresentational
コンポーネントの中で status
が成功になった場合に router.back()
によってブラウザバックをすることでモーダルを消す挙動を再現しています。
これが上記にある「・戻る操作で、前のルートに戻るのではなく、モーダルを閉じる。」という挙動になります。
...
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]);
...
...
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 の公式ドキュメントに沿って実装しました。今まであまりしっかり実装したことがなかったので、勉強にもなったので非常によかったです。
ログインボタンをクリックすると、Server Functions が実行されて zod による入力値のバリデーションが行われます。バリデーションで問題がなければ、あらかじめ登録されたユーザー情報と一致するかの検証が行われます。
ここで一致するユーザー情報があれば、セッション管理を行います。具体的には、createSession
関数によって JWT を作成して cookie に登録します。
// 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
}
);
}
...
...
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
でリクエストごとに確認取るのかなーと思いつつ、時間切れで未実装です。
関連しているコードはこちらになります。
OpenTelemetry での計装
オブザーバビリティとして OpenTelemetry による計装を行い Honeycomb にトレースを送って可視化してみました。
シンプルに instrumentation.node.ts
で OTLPExporter
を実装して Prisma に関する計装をライブラリ計装で行っています。
// 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 デプロイは割と記事が多かったので、下記のハマりどころを除いてそこまで難しさはありませんでした。
雑にこんな 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 /app/next.config.ts ./
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/node_modules/.prisma ./node_modules/.prisma
COPY /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
ファイルでファイルの参照先を変更することで解消しました。
✨ 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 に関する記述をしていませんでした。
# 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 /app/next.config.ts ./
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /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 に加えて、フロントエンド周りでも発信を継続できたらと思っています。読んでいただきありがとうございました。
アプリ概要
最後に参考程度にアプリケーションの概要を説明しておきます。
プロダクトやチーム単位でプロジェクトを作成して採用している技術を登録することで、技術スタックを共有することを想定したサンプルアプリケーションになります。
プロジェクト一覧画面:/projects
登録済みのプロジェクトを一覧して閲覧できるプロジェクト一覧画面です。
プロジェクト詳細画面:/projects/[id]
登録済みのプロジェクトの詳細として紐づいている採用している技術を表示するプロジェクト詳細画面です。
プロジェクト編集画面:/projects/[id]/edit
ログインしたユーザーが所属しているプロジェクトであれば編集モードボタンが表示され、そのボタンをクリックすることで遷移するプロジェクト編集画面です。
技術追加モーダル:/projects/[id]/edit/create
ログインしたユーザーによる操作が可能なプロジェクトへの採用している技術の追加モーダルです。
参照元:Parallel Routes & Intercepting Routes によるモーダル実装
技術削除モーダル:/projects/[id]/edit/delete
ログインしたユーザーによる操作が可能なプロジェクトへの採用している技術の削除モーダルです。
ログインモーダル:/login
ログインのためのモーダルです。
参考
Discussion