Turborepoで構築するmonorepo構成
はじめに
初めまして。株式会社MacbeePlanetでエンジニアをしているキタデと申します。
2025年3月に入社し、新規プロダクトの機能追加や改善業務に取り組んでいます。
弊社では、入社時のオンボーディングの一環として、プロダクトで採用されている技術スタックを理解することを目的に、簡単なアプリケーションを作成する取り組みを行っています。
本記事では、その一環として私が作成したTODOアプリを題材に、Turborepoを用いてmonorepoを構築する手順をご紹介します。
同様の構成を検討されている方や、Turborepoの導入を考えている方の参考になれば幸いです。
なお、使用する技術スタックは以下の通りです
- フロントエンド : React/Next.js(App Router)
- バックエンド : Hono
- ORM : DrizzleORM
- データベース : PostgreSQL
- CSSフレームワーク : TailwindCSS
- コンポーネントライブラリ : shadcn/ui
- パッケージマネージャ/runtime : Bun
完成系のイメージ
Monorepo環境の構築
monorepoとは?
monorepo(モノレポ)とは、複数のプロジェクト(例:フロントエンド、バックエンド、共通ライブラリなど)を1つのGitリポジトリ内にまとめて管理する手法のことで、以下のようなメリットがあります
- API仕様の変更に合わせて、フロントエンド・バックエンド両方を同時に更新できる
- UIコンポーネントやバリデーションロジックなどを共通化し全体で一元管理できる
- パッケージ間の依存関係を明確に把握できる
monorepoに関してさらに詳しく知りたい方は以下記事を参照してください
Turborepoとは?
Turborepoは、Vercelが開発したモノレポ向けの高速ビルドシステムです。モノレポを導入する際に発生しがちなビルド時間の肥大化や変更の影響範囲の管理などの課題を、効率的に解決するためのツールです。
今回はTurborepoを使用してmonorepoアプリケーションを作成していきます。ディレクトリ構成について
プロジェクト全体のディレクトリ構成は以下のようになります
todo-app/
├── apps
│ ├── server // Honoアプリケーション
│ │ ├── src
│ │ │ ├── db
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts // DBのスキーマ定義
│ │ │ ├── index.ts // エントリポイント
│ │ │ ├── routes
│ │ │ │ └── todo.route.ts // route定義
│ │ │ └── schemas
│ │ │ └── todo.ts // バリデーションスキーマ定義
│ │ ├── drizzle.config.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── web // Next.jsアプリケーション
│ ├── app
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── todos
│ │ ├── page.tsx
│ │ └── _presentation
│ │ └── actions.ts // server action定義
│ ├── components
│ ├── hooks
│ ├── lib
│ │ └── client.ts // Hono Client定義
│ ├── components.json
│ ├── eslint.config.js
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.mjs
│ └── tsconfig.json
├── packages
│ ├── eslint-config
│ ├── typescript-config
│ └── ui // プロジェクトで横断的に使用するuiコンポーネントや関数定義
├── bun.lock
├── package.json
├── tsconfig.json
└── turbo.json
セットアップ
turborepoのセットアップ
今回はshadcnも使用するので、shadcnのドキュメントに従ってインストールします。
$ pnpm dlx shadcn@canary init
monorepoを選択します
Would you like to start a new project? › - Use arrow-keys. Return to submit.
Next.js
❯ Next.js (Monorepo)
プロジェクト名を入力します
? What is your project named? › todo-app
今回は開発環境にbunを使用したいので、pnpm-lock.yml
とpnpm-workspace.yaml
を削除します。
$ rm pnpm-lock.yaml pnpm-workspace.yaml
ディレクトリのrootのpakage.json
のpackageManagerの指定をbunに変更します。また、bunにはworkspaceの指定用のファイルが存在しないので、pakage.json
内で指定しておきます。
- "packageManager": "pnpm@10.4.1",
+ "packageManager": "bun@1.2.4",
+ "workspaces": [
+ "apps/*",
+ "packages/*"
+ ],
ここまで終わったらbun i
してlockfileを生成しておきます。
Honoのセットアップ
appsに移動してserver
という名前のHonoアプリケーションを作成します。
templateとpackage managerを聞かれるのでどちらもbunを選択します
$ cd apps && bun create hono@latest server
create-hono version 0.16.0
✔ Using target directory … server
✔ Which template do you want to use? bun
✔ Do you want to install project dependencies? Yes
✔ Which package manager do you want to use? bun
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files
Get started with: cd server
DrizzleORMのセットアップ
次にORMの設定を行います。
基本的には公式に従って進めていきます。今回はPostgreSQLを使用します。
まずは以下コマンドをapps/server
配下で実行し必要なライブラリをインストールします。
$ bun add drizzle-orm @libsql/client dotenv
$ bun add -D drizzle-kit tsx
環境変数の設定
postgresユーザーでログインしてDBを作成しておきます
$ psql -U postgres
CREATE DATABASE todo_app_db
DBを作成したら、.env
ファイルにDBのURLを設定します
DATABASE_URL=postgresql://postgres@localhost:5432/todo_app_db
index.ts
ファイルをsrc/db
ディレクトリ配下に作成して接続の初期化用の記述を追加します
import "dotenv/config";
import { drizzle } from "drizzle-orm/node-postgres";
export const db = drizzle(process.env.DATABASE_URL!);
schema定義/migrate
schema定義
今回はシンプルなTODOアプリなので、以下のようなtodosテーブルを作成します
カラム | データ型 |
---|---|
id | int |
title | text |
completed | boolean |
createdAt | timestamp |
テーブル定義はsrc/db/schema.ts
にて記述します。
import { pgTable, serial, text, boolean, timestamp } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
export const todos = pgTable("todos", {
id: serial("id").notNull().primaryKey(),
title: text().notNull(),
completed: boolean("completed").notNull().default(false),
createdAt: timestamp("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
設定ファイルの記述
server/drizzle.config.ts
を作成し、以下の記述を追加します
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
out: './drizzle',
schema: './src/db/schema.ts',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
migrate
schema定義後、pushしてmigrationを行います
$ bunx drizzle-kit push
テーブルが作成されていることを確認してみましょう
$ psql -U postgres -d todo_app_db
todo_app_db=# \d todos
Table "public.todos"
Column | Type | Collation | Nullable | Default
------------+-----------------------------+-----------+----------+-----------------------------------
id | integer | | not null | nextval('todos_id_seq'::regclass)
title | text | | not null |
completed | boolean | | not null | false
created_at | timestamp without time zone | | not null | CURRENT_TIMESTAMP
Indexes:
"todos_pkey" PRIMARY KEY, btree (id)
できていますね!
API実装
続いて、TODOのCRUD操作を行うAPIを実装していきます。
エントリーポイントの設定
server/src/index.tsを以下のように定義します
const app = new Hono();
// middlewareとしてbuilt-inのcorsを使用
const _routes = app.use("*", cors()).route("/todos", todoRoute);
// 型共有をするのに必要な記述
export type AppType = typeof _routes;
export default {
port: 8787,
fetch: app.fetch,
};
Honoはapp.use
あるいはapp.HTTP_METHOD
で任意の処理をmiddlewareとして登録することができます。ここではbuilt-inのCORS Middlewareを使用していますが、他にもBarerer認証やログ用などの便利なMiddlewareも多数提供されています。
valibotのスキーマ定義
リクエストに対するvalidationを行うため、valibotでschemaを定義します。なお、こちらのファイルは後ほどフロントエンドからも参照します。
import {
maxLength,
object,
optional,
string,
boolean,
minLength,
pipe,
} from "valibot";
export const IdSchema = object({
id: string(),
});
export const CreateTodoSchema = object({
title: pipe(
string(),
minLength(1, "The string must be 1 or more characters long."),
maxLength(100, "The string must be 100 or less characters long.")
),
});
export const UpdateTodoSchema = object({
title: pipe(
string(),
minLength(1, "The string must be 1 or more characters long."),
maxLength(100, "The string must be 100 or less characters long.")
),
completed: optional(boolean()),
});
ルート定義
実際の処理をserver/src/routes/todo.route.ts
に記述していきます
server/src/routes/todo.route.ts
import { Hono } from "hono";
import { db } from "../db";
import { todos } from "../db/schema";
import { eq } from "drizzle-orm";
import { sValidator } from "@hono/standard-validator";
import { CreateTodoSchema, IdSchema, UpdateTodoSchema } from "../schemas/todos";
export const todoRoute = new Hono()
// TODO一覧取得
.get("/", async (c) => {
const result = await db.select().from(todos);
return c.json(result);
})
// TODO作成
.post(
"/",
sValidator("json", CreateTodoSchema, (result, c) => {
if (!result.success) {
const { message } = result.error[0];
return c.json({ message: message }, 400);
}
}),
async (c) => {
const todo = c.req.valid("json");
await db.insert(todos).values(todo);
return c.json({ message: "successfully created" }, 201);
}
)
// TODO更新
.put(
"/:id",
sValidator("param", IdSchema, (result, c) => {
if (!result.success) {
return c.json({ message: "invalid id" }, 400);
}
}),
sValidator("json", UpdateTodoSchema, (result, c) => {
if (!result.success) {
const { message } = result.error[0];
return c.json({ message: message }, 400);
}
}),
async (c) => {
const id = parseInt(c.req.valid("param").id);
const todo = c.req.valid("json");
const targetTodo = await db.select().from(todos).where(eq(todos.id, id));
if (targetTodo.length === 0) {
return c.json({ message: "todo not found" }, 404);
}
await db.update(todos).set(todo).where(eq(todos.id, id));
return c.json({ message: "successfully updated" }, 200);
}
)
// TODO削除
.delete(
"/:id",
sValidator("param", IdSchema, (result, c) => {
if (!result.success) {
return c.json({ message: "invalid id" }, 400);
}
}),
async (c) => {
const id = parseInt(c.req.valid("param").id);
const targetTodo = await db.select().from(todos).where(eq(todos.id, id));
if (targetTodo.length === 0) {
return c.json({ message: "todo not found" }, 404);
}
await db.delete(todos).where(eq(todos.id, id));
return c.json({ message: "successfully deleted" }, 200);
}
);
validator middlewareにはStandard Schemaに準拠したstandard-validatorを使用しています。
curlでAPIを叩いてみます
$ curl -X POST http://localhost:8787/todos -H "Content-Type: application/json" -d '{"title": "New Todo"}'
{"message":"successfully created"}
$ curl -X GET http://localhost:8787/todos
[{"id":1,"title":"New Todo","completed":false,"createdAt":"2025-04-21T11:05:13.329Z"}]%
ちゃんと動いてそうです!
フロントエンドの実装
バックエンドの準備が整ったところで、次にフロントエンドのUIを構築していきます。
ここでは、Next.jsのApp RouterとServer Actionsを活用しながら、Todoアプリの画面と処理を段階的に作っていきます。
APIクライアント定義
まず、バックエンドの型定義を再利用するため、server
パッケージを依存関係に追加します。
"dependencies": {
...,
+ "server": "workspace:*"
},
.env
には、開発環境でのAPIエンドポイントを指定しておきます。今回はローカルを想定しています。
NEXT_PUBLIC_API_URL=http://localhost:8787
ここまで完了したら、以下のようにHonoのAPIクライアントを定義します。APIクライアントの生成には、hono/client
を使用します
import { hc } from "hono/client";
import type { AppType } from "server/src/index";
export const client = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!);
export type Client = typeof client;
ServerActions定義
今回のアプリでは、データの取得・作成・更新・削除といった基本的な操作を、Next.jsのServer Actionsとして定義しています。
APIを呼び出す非同期処理をServerActionsに定義し、それをClientComponentから呼び出すようにします。
web/app/todos/_presentation
配下にaction用のファイルを作成し以下のように記述します。
server/src/routes/todo.route.ts
"use server";
import { client } from "@/lib/client";
import { safeParse } from "valibot";
import {
IdSchema,
CreateTodoSchema,
UpdateTodoSchema,
} from "server/src/schemas/todos";
import { revalidatePath } from "next/cache";
export const listTodos = async () => {
const res = await client.todos.$get();
return await res.json();
};
export const createTodo = async (_: unknown, formData: FormData) => {
try {
const input = {
title: formData.get("title"),
};
const parsed = safeParse(CreateTodoSchema, input);
if (!parsed.success) {
const { message } = parsed.issues[0];
return { message: message, success: false };
}
await client.todos.$post({
json: parsed.output,
});
revalidatePath("/todos");
return { message: "created successfully", success: true };
} catch (error) {
console.error(error);
return { message: "Failed to create todo", success: false };
}
};
export const updateTodo = async (_: unknown, formData: FormData) => {
try {
const inputPathParam = {
id: formData.get("id"),
};
const completedValue = formData.get("completed");
const input = {
title: formData.get("title"),
completed: completedValue === "true" ? true : false,
};
const parsedPathParam = safeParse(IdSchema, inputPathParam);
const parsed = safeParse(UpdateTodoSchema, input);
if (!parsedPathParam.success) {
const { message } = parsedPathParam.issues[0];
return { message: message, success: false };
}
if (!parsed.success) {
const { message } = parsed.issues[0];
return { message: message, success: false };
}
await client.todos[":id"].$put({
param: parsedPathParam.output,
json: parsed.output,
});
revalidatePath("/todos");
return { message: "updated successfully", success: true };
} catch (error) {
console.error(error);
return { message: "Failed to update todo", success: false };
}
};
export const deleteTodo = async (_: unknown, formData: FormData) => {
try {
const input = {
id: formData.get("id"),
};
const parsed = safeParse(IdSchema, input);
if (!parsed.success) {
const { message } = parsed.issues[0];
return { message: message, success: false };
}
await client.todos[":id"].$delete({
param: parsed.output,
});
revalidatePath("/todos");
return { message: "deleted successfully", success: true };
} catch (error) {
console.error(error);
return { message: "Failed to delete todo", success: false };
}
};
todosページの実装
/todos
にアクセスした時のページを実装していきます。
以下のような構成になっています:
- Todoの一覧表示(DataTable)
- 新規追加フォーム
- 編集・削除用のダイアログ
app/todos/page.tsx
import { listTodos, type TodoListResponse } from "./_presentations/actions";
import TodoCreateForm from "./_presentations/todo-create-form";
import { TodoDataTable } from "./_presentations/todo-data-table";
export default async function Page() {
const todos: TodoListResponse = await listTodos();
return (
<div className="flex items-center justify-center min-h-svh">
<div className="flex flex-col items-center justify-center gap-4">
<TodoCreateForm />
<TodoDataTable todos={todos} />
</div>
</div>
);
}
app/todos/_presentation/todo-create-form.tsx
"use client";
import Form from "next/form";
import { useActionState, useEffect } from "react";
import { createTodo } from "./actions";
import { Input } from "@workspace/ui/components/input";
import { Button } from "@workspace/ui/components/button";
import { toast } from "sonner";
export default function TodoCreateForm() {
const [state, action, isPending] = useActionState(createTodo, undefined);
useEffect(() => {
if (state?.success) {
toast.success(state.message);
} else if (state && !state.success) {
toast.error(state.message);
}
}, [state]);
return (
<Form action={action}>
<div className="flex w-full max-w-sm items-center space-x-2">
<Input type="text" name="title" placeholder="Enter a new task..." />
<Button type="submit" disabled={isPending}>
Add
</Button>
</div>
</Form>
);
}
app/todos/_presentation/todo-data-table.tsx
"use client";
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { TodoUpdateDialog } from "./todo-update-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@workspace/ui/components/table";
import { DeleteDialog } from "./todo-delete-dialog";
import { TodoListResponse } from "./actions";
export const columns: ColumnDef<TodoListResponse[number]>[] = [
{
accessorKey: "title",
header: "Title",
cell: ({ row }) => <div className="lowercase">{row.getValue("title")}</div>,
},
{
accessorKey: "completed",
header: "Status",
cell: ({ row }) => (
<div className="capitalize">
{row.getValue("completed") ? "completed" : "not completed"}
</div>
),
},
{
accessorKey: "update",
header: "",
cell: ({ row }) => (
<TodoUpdateDialog
id={row.original.id}
title={row.original.title}
completed={row.original.completed}
/>
),
},
{
accessorKey: "destroy",
header: "",
cell: ({ row }) => <DeleteDialog id={row.original.id} />,
},
];
export function TodoDataTable({ todos }: { todos: TodoListResponse }) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const table = useReactTable({
data: todos,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="w-full">
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
}
app/todos/_presentation/todo-update-dialog.tsx
"use client";
import { useActionState, useEffect, useState } from "react";
import { updateTodo } from "@/app/todos/_presentations/actions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
import { Pencil } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@workspace/ui/components/input";
export function TodoUpdateDialog({
id,
title,
completed,
}: {
id: number;
title: string;
completed: boolean;
}) {
const [state, action, isPending] = useActionState(updateTodo, undefined);
const [isChecked, setIsChecked] = useState(completed);
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setIsChecked(e.target.checked);
};
useEffect(() => {
if (state?.success) {
toast.success(state.message);
} else if (state && !state.success) {
toast.error(state.message);
}
}, [state]);
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Edit Todo">
<Pencil />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="sm:max-w-md">
<form action={action}>
<AlertDialogHeader>
<AlertDialogTitle>Edit Todo</AlertDialogTitle>
<AlertDialogDescription>
Make changes to your todo item below.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="grid gap-4 py-4">
<input type="hidden" name="id" value={id} />
<div className="grid gap-2">
<label htmlFor="title" className="text-sm font-medium">
Title
</label>
<Input
id="title"
type="text"
name="title"
defaultValue={title}
className="w-full"
/>
</div>
<div className="flex items-center space-x-2">
<input
id="completed"
type="checkbox"
name="completed"
checked={isChecked}
onChange={handleCheckboxChange}
value={isChecked ? "true" : "false"}
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="completed" className="text-sm font-medium">
Mark as completed
</label>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction type="submit" disabled={isPending}>
{isPending ? "Updating..." : "Update"}
</AlertDialogAction>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
);
}
app/todos/_presentation/todo-delete-dialog.tsx
"use client";
import { useActionState, useEffect } from "react";
import { deleteTodo } from "@/app/todos/_presentations/actions";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@workspace/ui/components/alert-dialog";
import { Button } from "@workspace/ui/components/button";
import { Trash } from "lucide-react";
import { toast } from "sonner";
export function DeleteDialog({ id }: { id: number }) {
const [state, action, isPending] = useActionState(deleteTodo, undefined);
useEffect(() => {
if (state?.success) {
toast.success(state.message);
} else if (state && !state.success) {
toast.error(state.message);
}
}, [state]);
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost">
<Trash />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your
account and remove your data from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<form action={action}>
<input type="hidden" name="id" value={id} />
<AlertDialogAction type="submit" disabled={isPending}>
Continue
</AlertDialogAction>
</form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
コンポーネントは基本的にshadcn/uiのものを使用しており、packages/ui
に共通化してあります。これにより、各アプリケーション間でのスタイル・UXの一貫性を保ちつつ、再利用性も確保できます。
また、form系の処理にはReactv19から導入されたuseActionStateを使用しております。ServerActionとの連携をシンプルに実現でき、ローディング状態や成功・失敗メッセージの制御も直感的に記述できて便利です。
まとめ
以上、Turborepo を活用して Monorepo 構成で TODO アプリを構築する手順をご紹介しました。
このような構成にすることで、
- フロントとバックエンドで型定義を共有できる
- コンポーネントやロジックをパッケージ化し、横断的に活用できる
といったメリットが得られるように感じました。
今後、Monorepoの導入やフロント・バックエンド統合のアーキテクチャを検討している方にとって、少しでも参考になれば幸いです。
Discussion