🪐

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に関してさらに詳しく知りたい方は以下記事を参照してください
https://monorepo.tools/
https://zenn.dev/burizae/articles/c811cae767965a

Turborepoとは?

Turborepoは、Vercelが開発したモノレポ向けの高速ビルドシステムです。モノレポを導入する際に発生しがちなビルド時間の肥大化や変更の影響範囲の管理などの課題を、効率的に解決するためのツールです。
https://turbo.build/
今回は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.ymlpnpm-workspace.yamlを削除します。

$ rm pnpm-lock.yaml pnpm-workspace.yaml 

ディレクトリのrootのpakage.jsonのpackageManagerの指定をbunに変更します。また、bunにはworkspaceの指定用のファイルが存在しないので、pakage.json内で指定しておきます。

package.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を設定します

server/.env
DATABASE_URL=postgresql://postgres@localhost:5432/todo_app_db

index.tsファイルをsrc/dbディレクトリ配下に作成して接続の初期化用の記述を追加します

server/src/db/index.ts
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にて記述します。

server/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を作成し、以下の記述を追加します

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を以下のように定義します

server/src/index
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を定義します。なお、こちらのファイルは後ほどフロントエンドからも参照します。

server/src/schemas/todo.ts
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
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パッケージを依存関係に追加します。

package.json
  "dependencies": {
    ...,
+    "server": "workspace:*"
  },

.envには、開発環境でのAPIエンドポイントを指定しておきます。今回はローカルを想定しています。

.env
NEXT_PUBLIC_API_URL=http://localhost:8787

ここまで完了したら、以下のようにHonoのAPIクライアントを定義します。APIクライアントの生成には、hono/clientを使用します

web/lib/client.ts
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
web/app/todos/_presentation/action.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
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
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
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
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
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の導入やフロント・バックエンド統合のアーキテクチャを検討している方にとって、少しでも参考になれば幸いです。

株式会社MacbeePlanet テックブログ

Discussion