💎

【T3 Stack】フロントエンド・バックエンドTypescript開発入門

2024/07/13に公開

はじめに

フロントエンドもバックエンドもTypescriptで書きたい!ということで、T3 Stack(T3スタック)について調べてみました。
T3 Stackを利用したプロジェクトを作成するためのCLIツールcreate-t3-appが用意されており、簡単に雛形プロジェクトが作れるため、実際に使ってみました。
この記事は以下の内容をメインに紹介します。

  • create-t3-appの環境構築手順
  • 雛形プロジェクトの解説(特にtRPCを用いたAPIの呼び出し方法について)

T3 Stackとは

T3 Stackとはsimplicity(簡潔さ)、modularity(モジュール性)、full-stack type safety(フルスタックの型安全)を追求した思想に焦点を当てています。
そしてそれらを実現するために以下6つの技術スタックが採用されています。
✅ Next.js
✅ tRPC
✅ NextAuth.js
✅ Prisma
✅ Tailwind CSS
✅ Typescript

T3 Stackを利用したプロジェクトを作成するためのCLIツールcreate-t3-appが用意されており、簡単に雛形プロジェクトが作れるため、実際に使ってみました。
https://github.com/t3-oss/create-t3-app
https://create.t3.gg/en/introduction

環境構築

コマンド実行します。

npm create t3-app@latest

技術スタックを使うか聞かれるので全部yesで進めます。(Git初期化だけ自分で行うためNoにしました)

Need to install the following packages:
create-t3-app@7.35.0
Ok to proceed? (y)
   ___ ___ ___   __ _____ ___   _____ ____    __   ___ ___
  / __| _ \ __| /  \_   _| __| |_   _|__ /   /  \ | _ \ _ \
 | (__|   / _| / /\ \| | | _|    | |  |_ \  / /\ \|  _/  _/
  \___|_|_\___|_/‾‾\_\_| |___|   |_| |___/ /_/‾‾\_\_| |_|


│
◇  What will your project be called?
│  t3-app
│
◇  Will you be using TypeScript or JavaScript?
│  TypeScript
│
◇  Will you be using Tailwind CSS for styling?
│  Yes
│
◇  Would you like to use tRPC?
│  Yes
│
◇  What authentication provider would you like to use?
│  NextAuth.js
│
◇  What database ORM would you like to use?
│  Prisma
│
◇  Would you like to use Next.js App Router?
│  Yes
│
◇  What database provider would you like to use?
│  SQLite (LibSQL)
│
◇  Should we initialize a Git repository and stage the changes?
│  No
│
◇  Should we run 'npm install' for you?
│  Yes
│
◇  What import alias would you like to use?
│  ~/

Using: npm

✔ t3-app scaffolded successfully!

プロジェクト作成成功しました!

立ち上げようとするもエラーが発生

npm run dev
❌ Invalid environment variables: {
  DISCORD_CLIENT_ID: [ 'Required' ],
  DISCORD_CLIENT_SECRET: [ 'Required' ]
}

.envの値がないようです。
NextAuth.jsを使用する設定にしたため、プロバイダの設定が必要なようです。

認証設定

デフォルトのプロバイダーはDiscordになっているためDiscordのアカウントを作成してDISCORD_CLIENT_IDDISCORD_CLIENT_SECRETを発行しました。

手順:https://create.t3.gg/en/usage/first-steps#authentication

DB設定

アプリケーションに Prisma を含めた場合には、アプリケーションのルートディレクトリからnpx prisma db pushを実行してください。

package.jsonを見て該当する以下コマンドを実行

npm run db:push

立ち上げる

DB、認証設定を行って再び立ち上げてみます。

npm run dev

無事立ち上がりました。

Sign inします。
(怖?)

怖かったのですが一応成功したみたいです...

DB確認

Prismaを導入しているためDBをGUI操作できるみたいです。

npm run db:studio

localhost:5555 でPrisma Studioが起動しました。

Accountテーブルを見てみるとちゃんとログイン情報が入っていそうです。

雛形で作成されたschema.prismaファイルのモデルからDBのテーブルが作成されていました。

prisma/schema.prisma
// Necessary for Next auth
model Account {
    id                       String  @id @default(cuid())
    userId                   String
    type                     String
    provider                 String
    providerAccountId        String
    refresh_token            String? // @db.Text
    access_token             String? // @db.Text
    expires_at               Int?
    token_type               String?
    scope                    String?
    id_token                 String? // @db.Text
    session_state            String?
    user                     User    @relation(fields: [userId], references: [id], onDelete: Cascade)
    refresh_token_expires_in Int?

    @@unique([provider, providerAccountId])
}

NextAuth.js と Prisma を組み合わせて選択すると、User, Session, Account, VerificationTokenモデルの推奨値がNextAuth.js ドキュメント↗に従って設定されてスキーマファイルが生成されます。

さらにNextAuth.jsを有効にしたためUser, Session, Account, VerificationTokenテーブルが自動で作成されたようです。

.prismaファイルをvscodeで見やすくするためにこの拡張機能をインストールしました!
Prisma

雛形プロジェクト構成

重要なものだけ抜粋

t3-app/
├── prisma/  # Prisma関連
│   ├── db.sqlite
│   └── schema.prisma  # Prismaスキーマ定義ファイル
├── src/
│   ├── app/  # Next.jsのコードが格納されているディレクトリ
│   │   ├── _components/
│   │   │   └── create-post.tsx  # クライアントコンポーネント(use client)
│   │   ├── api/
│   │   │   ├── auth  # NextAuth.js関連
│   │   │   │   └── [...nextauth]
│   │   │   │       └── route.ts  # NextAuth.jsの設定とルーティングを処理
│   │   │   └── trpc
│   │   │       └── [trpc]
│   │   │           └── route.ts  # tRPCのエンドポイントのルーティングを処理
│   │   ├── layout.tsx
│   │   └── page.tsx  
│   ├── server/  # サーバー側のコードが格納されているディレクトリ
│   │   ├── api/
│   │   │   ├── root.ts  # tRPCルーターのルート設定、全てのエンドポイントをまとめる
│   │   │   ├── routers
│   │   │   │   └── post.ts # tRPCサブルーターの定義、投稿のCRUD操作に関するAPIの定義
│   │   │   └── trpc.ts  # tRPCの設定とコンテキストの作成、初期化処理
│   │   ├── auth.ts  # 認証関連のロジックを含むファイル
│   │   └── db.ts  # データベース接続の設定と初期化を行う、Prismaクライアントの作成
│   └── trpc/  # tRPC関連
│       ├── react.tsx  # クライアント側で使用するためのtRPCクライアント設定
│       └── server.ts  # サーバー側で使用するためのtRPCクライアント設定
├── .env
├── package.json
└── tailwind.config.ts  # Tailwind CSS設定

ここから先はどのような流れでAPI呼び出しが行われているのかを動かしながら調べたので紹介していきます。

tRPC

tRPCは、TypeScriptを使用してサーバーからクライアントまで一貫した型安全なAPIを構築するためのフレームワークです。サーバーとクライアント間で型情報を共有し、スキーマ定義やコード生成を必要とせずに型安全性を実現します。

サーバー側とクライアント側でAPIを呼び出す仕組み

サーバー側からは、tRPC関数を直接呼び出して処理することができます。
一方、クライアント側は、HTTPリクエストを介してAPIエンドポイントを呼び出し、Next.jsのAPIルートを通じて処理されます。

tRPCの初期化

tRPCの初期化処理はserver/api/trpc.tsで行われています。

server/api/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";

import { getServerAuthSession } from "~/server/auth";
import { db } from "~/server/db";

// コンテキストの作成
export const createTRPCContext = async (opts: { headers: Headers }) => {
  const session = await getServerAuthSession();

  return {
    db,
    session,
    ...opts,
  };
};

// tRPCの初期化
const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});

// ルーターを作成する関数
export const createTRPCRouter = t.router;

// 認証不要のプロシージャ
export const publicProcedure = t.procedure;

// 認証が必要なプロシージャ
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.session || !ctx.session.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      // infers the `session` as non-nullable
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

ここではtRPCのコンテキストの作成、tRPCの初期化、ルーターとプロシージャの定義が行われています。
コンテキストとは、各リクエストに共通する情報(セッション情報やデータベース接続など)を提供する仕組みのことを指します。
このコンテキストは、tRPCのプロシージャ内で利用されます。

プロシージャとはリクエストを受け取り、特定の処理を行うための定義です。
例えばprotectedProcedurectx(セッション情報やデータベース接続などが含まれたコンテキストオブジェクト)を受け取り、ctx.sessionおよびctx.session.userを見てユーザーが認証されているかどうかを確認します。

tRPCとZodでAPI定義

どこにAPIが定義されているかを確認します。server/api/routers/post.tsには、次のようにAPIが定義されています。
(tRPCを使用して作成されたプロシージャがAPIエンドポイントとして機能します。)

server/api/routers/post.ts
import { z } from "zod";

import {
  createTRPCRouter,
  protectedProcedure,
  publicProcedure,
} from "~/server/api/trpc";

export const postRouter = createTRPCRouter({
  // 1つのAPIの入出力を定義。
  hello: publicProcedure
    // 入力にはZodのスキーマバリデーションライブラリを使用
    .input(z.object({ text: z.string() })) // textプロパティで文字列の値のオブジェクトを受け取る
    // 入力データをもとに出力するデータを定義
    .query(({ input }) => {
      return {
        greeting: `Hello ${input.text}`,
      };
    }),

  create: protectedProcedure
    .input(z.object({ name: z.string().min(1) }))
    .mutation(async ({ ctx, input }) => {
      // simulate a slow db call
      await new Promise((resolve) => setTimeout(resolve, 1000));

      return ctx.db.post.create({
        data: {
          name: input.name,
          createdBy: { connect: { id: ctx.session.user.id } },
        },
      });
    }),

  getLatest: protectedProcedure.query(({ ctx }) => {
    return ctx.db.post.findFirst({
      orderBy: { createdAt: "desc" },
      where: { createdBy: { id: ctx.session.user.id } },
    });
  }),

  getSecretMessage: protectedProcedure.query(() => {
    return "you can now see this secret message!";
  }),
});

ここではpostRouterが定義されており、"hello"、"create"、"getLatest"、"getSecretMessage"の4つのプロシージャ(APIエンドポイント)があります。
前述した認証不要のプロシージャ、認証必要のプロシージャを使い分けることによって認証有無を管理することができます。

例えば"hello"は認証不要で{ text: "文字列" }を受け取り{ greeting: Hello ${input.text} }を返却します。
この入力にはZodというスキーマバリデーションライブラリが使用されています。

tRPCルーター(appRouter)に全てのエンドポイントを集約

server/api/root.tsにて、全てのエンドポイントを集約したtRPCルーター(appRouter)を作成します。

server/api/root.ts
import { postRouter } from "~/server/api/routers/post";
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";

// アプリケーション全体のtRPCルーター(appRouter)を作成。
// このappRouterはサーバー側、クライアント側それぞれに提供される
export const appRouter = createTRPCRouter({
  post: postRouter, // postに関するAPIエンドポイントを定義したルーターを追加
});

// appRouterの型定義
export type AppRouter = typeof appRouter;

// サーバーサイドでAPIを呼び出すための関数
export const createCaller = createCallerFactory(appRouter);

postRouterをルートのappRouterに追加し、全てのエンドポイントを集約します。このappRouterはサーバー側とクライアント側の両方で使用されます。

参照先に飛ぶことで確認できます。

サーバー側にはAPIを呼び出すためのcreateCallerに設定。
server/api/root.ts

クライアント側にはAPIルートのハンドラーに設定。
app/api/trpc/[trpc]/route.ts

以降サーバー側、クライアント側について分けて解説します。

サーバー側で呼び出すためのセットアップ

コンテキストの作成

サーバー側からtRPCを呼び出すためのコンテキストを作成します。

trpc/server.ts
import "server-only";

import { headers } from "next/headers";
import { cache } from "react";

import { createCaller } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";

const createContext = cache(() => {
  const heads = new Headers(headers());
  heads.set("x-trpc-source", "rsc");

  return createTRPCContext({
    headers: heads,
  });
});

export const api = createCaller(createContext);

createContextで作成したコンテキストはサーバー側でtRPCを呼び出すための関数createCallerに設定し、tRPCクライアントを作成します。

サーバーコンポーネントでの呼び出し

app/page.tsx
import { api } from "~/trpc/server";

export default async function Home() {
  // tRPCクライアントを直接呼び出すだけ
  const hello = await api.post.hello({ text: "from tRPC" });

  return (
    <p className="text-2xl text-white">
      {hello ? hello.greeting : "Loading tRPC query..."}
    </p>
  );
}

Next.jsのサーバーコンポーネントでの呼び出しは、createCallerから作成したtRPCクライアントを直接呼び出すだけです。

"hello"は{ text: "from tRPC" }を受け取り{ greeting: Hello ${input.text} }を返すAPIでした。
このように表示されています。

クライアント側で呼び出すためのセットアップ

続いて、クライアント側でtRPCを呼び出すための設定について説明します。

TanstackQuery + tRPC機能をReactコンポーネントで使用できるようにする

Tanstack Queryはデータフェッチングとキャッシングを行うライブラリです。tRPCクライアントと統合することで、Reactコンポーネントでこれらの機能を利用できます。

trpc/react.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";

import { type AppRouter } from "~/server/api/root";

// TanstackQueryクライアントを作成
const createQueryClient = () => new QueryClient();

let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
  if (typeof window === "undefined") {
    // Server: always make a new query client
    return createQueryClient();
  }
  // Browser: use singleton pattern to keep the same query client
  return (clientQueryClientSingleton ??= createQueryClient());
};

export const api = createTRPCReact<AppRouter>();

export function TRPCReactProvider(props: { children: React.ReactNode }) {
  const queryClient = getQueryClient();

  // tRPCクライアントを作成
  const [trpcClient] = useState(() =>
    api.createClient({
      links: [
        loggerLink({
          enabled: (op) =>
            process.env.NODE_ENV === "development" ||
            (op.direction === "down" && op.result instanceof Error),
        }),
        unstable_httpBatchStreamLink({
          transformer: SuperJSON,
          url: getBaseUrl() + "/api/trpc",
          headers: () => {
            const headers = new Headers();
            headers.set("x-trpc-source", "nextjs-react");
            return headers;
          },
        }),
      ],
    }),
  );

  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
}

function getBaseUrl() {
  if (typeof window !== "undefined") return window.location.origin;
  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
  return `http://localhost:${process.env.PORT ?? 3000}`;
}

ここでは、TanstackQueryクライアントとtRPCクライアントを作成し、2つのプロバイダーを介して提供しています。
これにより、ReactコンポーネントでTanstack QueryとtRPCの機能を利用できます。

実際にReactコンポーネントで使うためには2つのプロバイダーを統合したTRPCReactProviderをlayout.tsxでラップする必要があります。

app/layout.tsx
import { TRPCReactProvider } from "~/trpc/react";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" className={`${GeistSans.variable}`}>
      <body>
        <TRPCReactProvider>{children}</TRPCReactProvider>
      </body>
    </html>
  );
}

Next.jsのAPIルートハンドラーの設定

クライアント側からのHTTPリクエストを受け取り、Next.jsのAPIルートを介して処理するためのハンドラーを設定します。

app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";

import { env } from "~/env";
import { appRouter } from "~/server/api/root";
import { createTRPCContext } from "~/server/api/trpc";

// クライアント側からのtRPC呼び出し用のコンテキストの作成
const createContext = async (req: NextRequest) => {
  return createTRPCContext({
    headers: req.headers,
  });
};

const handler = (req: NextRequest) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: () => createContext(req),
    onError:
      env.NODE_ENV === "development"
        ? ({ path, error }) => {
            console.error(
              `❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
            );
          }
        : undefined,
  });

export { handler as GET, handler as POST };

前提としてapp/api/trpc/[trpc]/route.tsファイルを作成すると、Next.jsは"/api/trpc/"に続く任意のパスに対してリクエストを処理するエンドポイントを提供します。

ハンドラーにはrouterやクライアント専用のコンテキストの設定をします。

作成したハンドラーをexport { handler as GET, handler as POST };のようにexportすることでGETリクエストとPOSTリクエストがこのハンドラーで処理されるようになります。

クライアントコンポーネントでの呼び出し

app/_components/create-post.tsx
"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";

import { api } from "~/trpc/react";

export function CreatePost() {
  const router = useRouter();
  const [name, setName] = useState("");

 // クライアントコンポーネントでの呼び出し
  const createPost = api.post.create.useMutation({
    onSuccess: () => {
      router.refresh();
      setName("");
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        createPost.mutate({ name });
      }}
      className="flex flex-col gap-2"
    >
      <input
        type="text"
        placeholder="Title"
        value={name}
        onChange={(e) => setName(e.target.value)}
        className="w-full rounded-full px-4 py-2 text-black"
      />
      <button
        type="submit"
        className="rounded-full bg-white/10 px-10 py-3 font-semibold transition hover:bg-white/20"
        disabled={createPost.isPending}
      >
        {createPost.isPending ? "Submitting..." : "Submit"}
      </button>
    </form>
  );
}

TanstackQueryを導入したことでuseMutationを使用できます。

これにより、レスポンンスデータだけではなく、フェッチングの状態(pending、success、error)を管理することができます。

Submitを押すと(pending中...)

成功するとrouter.refresh()処理でリロードされ最新のpostが表示されます。

最新のpostが表示されている部分の処理(サーバーコンポーネント)

app/page.tsx
// サーバーコンポーネント
async function CrudShowcase() {
  const session = await getServerAuthSession();
  if (!session?.user) return null;

  // 最新のpostを取得
  const latestPost = await api.post.getLatest();

  return (
    <div className="w-full max-w-xs">
      {latestPost ? (
        <p className="truncate">Your most recent post: {latestPost.name}</p>
      ) : (
        <p>You have no posts yet.</p>
      )}

      <CreatePost />
    </div>
  );
}

まとめ

それぞれのファイルを説明してきましたがこれらは全て雛形です。Next.jsの雛形からtRPCやNextAuth.js、Prismaなどを一つ一つ追加していくのは非常に大変だと思いますが、create-t3-appでここまで提供されているのは非常に便利だと感じました。これを基に、新しいルーターを追加していって個人アプリを簡単に作成できそうです(自分はPrismaの入門が必要ですが...)。

全てをTypeScriptで書いているため、参照元に飛んで処理を追いやすいところも個人的に非常に良い点だと思いました!
また、Next.jsのappRouterを通じてサーバー側とクライアント側の処理の区別を意識するようになりましたが、tRPCを使うことでさらに意識して実装していく必要があると感じました。

tRPCやPrismaは初心者のため、間違っている点があれば教えてください...
Typescript開発を行おうとしている人に少しでも参考になれば嬉しいです!

Discussion