Next.js 14 で tRPC 10 をセットアップする
はじめに
昨日、別のスクラップで Next.js に tRPC をセットアップしたのだがその過程をほぼ記録していなかった。
せっかくなので記録に残したいと思い、復習を兼ねて手順を記録していこう。
tRPC 公式 Web サイト
Next.js セットアップ
Next.js プロジェクト作成
npx create-next-app@latest \
--typescript \
--tailwind \
--eslint \
--src-dir \
--import-alias "@/*" \
--use-npm \
nextjs-trpc-setup
cd nextjs-trpc-setup
Would you like to use App Router? には No と答える。
tRPC 関連ファイル作成
mkdir -p src/pages/api/trpc
touch src/pages/api/trpc/\[trpc\].ts
mkdir -p src/server/routers
touch src/server/routers/_app.ts
touch src/server/routers/post.ts
touch src/server/context.ts
touch src/server/trpc.ts
mkdir -p src/utils
touch src/utils/trpc.ts
作成したファイルは下記の 6 点。
- src/pages/api/trpc/[trpc].ts
- src/server/routers/_app.ts
- src/server/routers/post.ts
- src/server/context.ts
- src/server/trpc.ts
- src/utils/trpc.ts
npm パッケージのインストール
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query@^4.0.0 zod
tsconfig.json の strict モードの確認
compilerOptions.strict が true になっていることを確認する。
{
"compilerOptions": {
"strict": true
}
}
これは Zod を入力バリデーションに使用するために必要なようだ。
これを true にすると色々な strictXXX オプションを全て true になるようだ。
server/trpc.ts のコーディング
- src/pages/api/trpc/[trpc].ts
- src/server/routers/_app.ts
- src/server/routers/post.ts
- src/server/context.ts
- src/server/trpc.ts ← これ
- src/utils/trpc.ts
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
ドキュメントだと export const publicProcedure = t.procedure;
になっているが別ページを見ると publicProcedure の方が良さそうなのでこちらを採用した。
このページも興味深い
server/routers/_app.ts のコーディング
- src/pages/api/trpc/[trpc].ts
- src/server/routers/_app.ts ← これ
- src/server/routers/post.ts
- src/server/context.ts
- src/server/trpc.ts ← 完了
- src/utils/trpc.ts
import { z } from "zod";
import { publicProcedure, router } from "../trpc";
export const appRouter = router({
hello: publicProcedure
.input(
z.object({
text: z.string(),
})
)
.query((opts) => {
return {
greeting: `hello ${opts.input.text}`,
};
}),
});
export type AppRouter = typeof appRouter;
pages/api/trpc/[trpc].ts のコーディング
- src/pages/api/trpc/[trpc].ts ← これ
- src/server/routers/_app.ts ← 完了
- src/server/routers/post.ts
- src/server/context.ts
- src/server/trpc.ts ← 完了
- src/utils/trpc.ts
import { appRouter } from "@/server/routers/_app";
import { createNextApiHandler } from "@trpc/server/adapters/next";
export default createNextApiHandler({
router: appRouter,
createContext: () => ({}),
});
公式ドキュメントの書き方は下記の通りなのだが trpcNext が入るのはムダな気がする。
import * as trpcNext from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
// export API handler
// @see https://trpc.io/docs/server/adapters
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: () => ({}),
});
上のページでは import * as trpcNext
は使われていない。
utils/trpc.ts のコーディング
- src/pages/api/trpc/[trpc].ts ← 完了
- src/server/routers/_app.ts ← 完了
- src/server/routers/post.ts
- src/server/context.ts
- src/server/trpc.ts ← 完了
- src/utils/trpc.ts ← これ
import type { AppRouter } from "@/server/routers/_app";
import { httpBatchLink } from "@trpc/client";
import { createTRPCNext } from "@trpc/next";
export const trpc = createTRPCNext<AppRouter>({
config(opts) {
return {
links: [
httpBatchLink({
url: "/api/trpc",
}),
],
};
},
});
公式ドキュメント通りだともっと長いですが SSR を使わない場合は上記だけでも大丈夫そう。
現在の状態
- src/pages/api/trpc/[trpc].ts ← 完了
- src/server/routers/_app.ts ← 完了
- src/server/routers/post.ts
- src/server/context.ts
- src/server/trpc.ts ← 完了
- src/utils/trpc.ts ← 完了
まだ完了していないファイルがあるが後からやっていくことにしよう。
pages/_app.tsx の編集
import "@/styles/globals.css";
import { trpc } from "@/utils/trpc";
import type { AppProps, AppType } from "next/app";
const App: AppType = ({ Component, pageProps }) => {
return <Component {...pageProps} />;
};
export default trpc.withTRPC(App);
pages/index.tsx の編集
import { trpc } from "@/utils/trpc";
export default function Home() {
const { isLoading, data } = trpc.hello.useQuery({ text: "text" });
return (
<main className="container mx-auto px-4">
<h1 className="mt-4 mb-4 text-2xl">Next.js + tRPC Setup</h1>
{isLoading ? (
<p>Loading...</p>
) : (
<dl className="mb-4">
<dt className="font-bold">Result</dt>
<dd>{data?.greeting}</dd>
</dl>
)}
</main>
);
}
@tailwind base;
@tailwind components;
@tailwind utilities;
実行結果
Suspense を使おうとすると
勝手に SSR になるのはなぜだろう?
解決は難しそう
どうしても Suspense を使わなければならない理由がない限りは useSuspenseQuery を使うのは控えよう。
server/routers/post.ts のコーディング
- src/pages/api/trpc/[trpc].ts ← 完了
- src/server/routers/_app.ts ← 完了
- src/server/routers/post.ts ← これ
- src/server/context.ts
- src/server/trpc.ts ← 完了
- src/utils/trpc.ts ← 完了
import { z } from "zod";
import { publicProcedure, router } from "../trpc";
export const postRouter = router({
get: publicProcedure
.input(
z.object({
id: z.number(),
})
)
.query((opt) => {
return {
id: opt.input.id,
content: "content",
};
}),
});
import { z } from "zod";
import { publicProcedure, router } from "../trpc";
import { postRouter } from "./post";
export const appRouter = router({
hello: publicProcedure
.input(
z.object({
text: z.string(),
})
)
.query(async (opts) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
greeting: `hello ${opts.input.text}`,
};
}),
post: postRouter,
});
export type AppRouter = typeof appRouter;
import { trpc } from "@/utils/trpc";
import { FC } from "react";
export default function Home() {
return (
<main className="container mx-auto px-4">
<h1 className="mt-4 mb-4 text-2xl">Next.js + tRPC Setup</h1>
<Result></Result>
</main>
);
}
const Result: FC = () => {
const { isLoading, data } = trpc.post.get.useQuery({ id: 1 });
if (isLoading || !data) {
return <p>Loading...</p>;
}
return (
<dl className="mb-4">
<dt className="font-bold">Post ID</dt>
<dd className="mb-2">{data.id}</dd>
<dt className="font-bold">Content</dt>
<dd>{data.content}</dd>
</dl>
);
};
実行結果
server/context.ts のコーディング
- src/pages/api/trpc/[trpc].ts ← 完了
- src/server/routers/_app.ts ← 完了
- src/server/routers/post.ts ← 完了
- src/server/context.ts ← これ
- src/server/trpc.ts ← 完了
- src/utils/trpc.ts ← 完了
import { CreateNextContextOptions } from "@trpc/server/adapters/next";
export const createContext = async (opts: CreateNextContextOptions) => {
const session = { userId: "userId" };
return { session };
};
export type Context = Awaited<ReturnType<typeof createContext>>;
import { initTRPC } from "@trpc/server";
import { Context } from "./context";
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
import { createContext } from "@/server/context";
import { appRouter } from "@/server/routers/_app";
import { createNextApiHandler } from "@trpc/server/adapters/next";
export default createNextApiHandler({
router: appRouter,
createContext,
});
import { z } from "zod";
import { publicProcedure, router } from "../trpc";
export const postRouter = router({
get: publicProcedure
.input(
z.object({
id: z.number(),
})
)
.query((opt) => {
return {
id: opt.input.id,
content: "content",
userId: opt.ctx.session.userId,
};
}),
});
import { trpc } from "@/utils/trpc";
import { FC } from "react";
export default function Home() {
return (
<main className="container mx-auto px-4">
<h1 className="mt-4 mb-4 text-2xl">Next.js + tRPC Setup</h1>
<Result></Result>
</main>
);
}
const Result: FC = () => {
const { isLoading, data } = trpc.post.get.useQuery({ id: 1 });
if (isLoading || !data) {
return <p>Loading...</p>;
}
return (
<dl className="mb-4">
<dt className="font-bold">Post ID</dt>
<dd className="mb-2">{data.id}</dd>
<dt className="font-bold">Content</dt>
<dd className="mb-2">{data.content}</dd>
<dt className="font-bold">User ID</dt>
<dd className="mb-2">{data.userId}</dd>
</dl>
);
};
おわりに
昨日は急いでいたのでコピペで済ませてしまったが、今回は手打ちでじっくり学べて良かった。
今度はユーザー認証と組み合わせる方法について学んでみたい。
GitHub リポジトリ
MIT ライセンスでご利用いただけます。
最小限セットアップ
npx create-next-app@latest \
--typescript \
--tailwind \
--eslint \
--src-dir \
--import-alias "@/*" \
--use-npm \
nextjs-trpc-minimal-setup
cd nextjs-trpc-minimal-setup
npm install @trpc/server @trpc/client @trpc/next zod
mkdir -p src/pages/api/trpc
touch src/pages/api/trpc/\[trpc\].ts
import { initTRPC } from "@trpc/server";
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { z } from "zod";
const t = initTRPC.create();
const router = t.router;
const publicProcedure = t.procedure;
const appRouter = router({
greeting: publicProcedure
.input(
z.object({
name: z.string(),
})
)
.query((opt) => {
return {
text: `Hello ${opt.input.name}`,
};
}),
});
export type AppRouter = typeof appRouter;
export default createNextApiHandler({ router: appRouter });
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'
import Head from 'next/head'
import type { AppRouter } from './api/trpc/[trpc]'
import { useEffect, useState } from 'react'
const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
}),
]
})
export default function Home() {
const [text, setText] = useState('')
useEffect(() => {
trpc.greeting.query({ name: 'name' })
.then(({ text }) => setText(text))
}, [])
return (
<main className="container mx-auto p-4">
<Head>
<title>Next.js + tRPC Minimal Setup</title>
</Head>
<h1 className="mt-4 mb-4 text-2xl">Next.js + tRPC Minimal Setup</h1>
<dl className="mb-4">
<dt className="font-bold">Result</dt>
<dd className="mb-2">{text}</dd>
</dl>
</main>
)
}
実行結果
SWR を使う場合
npm install swr
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'
import Head from 'next/head'
import type { AppRouter } from './api/trpc/[trpc]'
import useSWR from 'swr'
const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
}),
]
})
export default function Home() {
const { data } = useSWR('greeting', () => trpc.greeting.query({ name: 'name' }))
return (
<main className="container mx-auto p-4">
<Head>
<title>Next.js + tRPC Minimal Setup</title>
</Head>
<h1 className="mt-4 mb-4 text-2xl">Next.js + tRPC Minimal Setup</h1>
{!data ? <p className="mb-4">Loading...</p> : (<dl className="mb-4">
<dt className="font-bold">Result</dt>
<dd className="mb-2">{data.text}</dd>
</dl>)}
</main>
)
}
最小限セットアップの GitHub リポジトリ
こちらも MIT ライセンスでご利用になれます。