Next.js, Prisma, Apollo GraphQL, Nexusで作るシンプルTODOアプリ
この記事はClassi developers Advent Calendar 2021の6日目の記事です。
この記事では、Next.jsをベースに、Prisma・Apollo GraphQL・GraphQL Nexusを組み合わせて簡易的なTODOアプリを実装してみたのでその手順をまとめます。
これらの組み合わせにより、バックエンドもフロントエンドもすべてTypeScriptで記述することができます。
全体構成
主に使用するフレームワーク・ライブラリ
-
Next.js
- Reactフレームワーク
-
Prisma
- Node.jsとTypeScriptのためのORM
- クエリの結果が型付けされるため、開発体験を向上させることができる特徴がある
- 本書ではPostgreSQLと接続してデータの取得・操作を行う
-
Apollo GraphQL
- GraphQLサーバ(Apollo Server)およびGraphQLクライアント(Apollo Client)を提供するライブラリ
- Apollo GraphQLはさまざまなサービスと接続し、あらゆるデータを集約することができる
- 本書ではApollo ServerにPrismaを接続してデータの取得・操作を行い、Apollo ClientがNext.jsにデータを提供するようにする
-
GraphQL Nexus
- Nexusを使うと、コードからGraphQLのスキーマ定義とTypeScriptの型定義ファイルを自動生成してくれる
- これにより、スキーマと型定義が同期されるため、スキーマの変更が容易になる
システム構成
次のような構成でデータの取得や操作を行うようにします
作るもの
以下のようなシンプルなCRUD機能だけを持つTODOアプリを作ります
- タスク一覧を表示できる(Read)
- タイトルを入力してタスクを追加できる(Create)
- タスクを削除できる(Delete)
- チェックボックスをクリックして完了/未完了を変更できる(Update)
DBスキーマ
簡易化のため以下のような属性を持つTaskエンティティのみを用意することにします
tasks
- id
- title
- done
- createdAt
- updatedAt
リポジトリ
最終的なコードはこのリポジトリで公開しています
Next.jsプロジェクトの作成
次のコマンドでNext.jsのプロジェクトを作成します
npx create-next-app next-graphql-simple-todo-app --typescript
TypeScriptを使用するため--typescript
オプションを付けています
サーバを起動して http://localhost:3000 を開くとウェルカム画面が表示されます
cd next-graphql-simple-todo-app
npm run dev
トップ画面のコードをクリーンにしておきます
import type { NextPage } from 'next'
import Head from 'next/head'
const Home: NextPage = () => {
return (
<div>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<h1>Hello World</h1>
</main>
</div>
)
}
export default Home
Prismaでマイグレーションを実行する
Prismaのセットアップ
Prismaをインストールします
npm install prisma --save-dev
Prismaのセットアップをするため次のコマンドを実行します
npx prisma init
✔ Your Prisma schema was created at prisma/schema.prisma
You can now open it in your favorite editor.
warn You already have a .gitignore. Don't forget to exclude .env to not commit any secret.
Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver or mongodb (Preview).
3. Run prisma db pull to turn your database schema into a Prisma schema.
4. Run prisma generate to generate the Prisma Client. You can then start querying your database.
More information in our documentation:
https://pris.ly/d/getting-started
PostgreSQLのデータベースを作成する
今回はローカルのPostgreSQLをデータベースに使用します
PostgreSQLのセットアップが整っている前提で進めます
次のコマンドで新しくデータベースを作成します
createdb next_graphql_simple_todo_app
prisma initの時に作られた.env
ファイルを開き、環境変数DATABASE_URLの値を変更します
DATABASE_URL="postgresql://<USER>:<PASSWORD>@localhost:5432/next_graphql_simple_todo_app?schema=public"
<USER>と<PASSWORD>には自分の情報を入れます
DBスキーマを定義する
prisma initの時に作られたprisma/schema.prisma
にDBスキーマを定義します
今回はTaskエンティティのみ定義します
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Task {
id Int @id @default(autoincrement())
title String @db.VarChar(100)
done Boolean @default(false)
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
@@map(name: "tasks")
}
Prismaのスキーマ定義の記法は次のページをご覧ください
ちなみに次のコマンドで、このスキーマファイル(schema.prisma)のフォーマットを行うことができます
npx prisma format
マイグレーション
次のコマンドを実行して、マイグレーションを行います
npx prisma db migrate dev --name init
prisma/migrations/${timestamp}_init/migration.sql
にマイグレーションで発行されたSQLが保存されます
-- CreateTable
CREATE TABLE "tasks" (
"id" SERIAL NOT NULL,
"title" VARCHAR(100) NOT NULL,
"done" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tasks_pkey" PRIMARY KEY ("id")
);
スキーマファイルの定義を追加や変更、削除した場合、再度マイグレーションコマンドを実行することで差分がprisma/migrations
に保存されていきます
マイグレーションに関するコマンドの詳細は次のページをご覧ください
seedデータを投入する
まず、PrismaClientのインスタンスを作成するために、lib/prisma.ts
を作成します
import { PrismaClient } from '@prisma/client'
declare global {
var prisma: PrismaClient | undefined
}
export const prisma =
global.prisma ||
new PrismaClient({
log: ['query'],
})
if (process.env.NODE_ENV !== 'production') global.prisma = prisma
このコードに関してはPrisma公式がベストプラクティスを提示しています
次に、prisma/seed.ts
を作成します
import { prisma } from '../lib/prisma'
const main = async () => {
await prisma.task.createMany({
data: [
{ title: 'sample task 1', done: true },
{ title: 'sample task 2', done: true },
{ title: 'sample task 3', done: false },
]
})
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
初期データを投入するためのコマンドを定義します
まず、必要なパッケージをインストールします
npm install -D ts-node @types/node
次に、package.json
ファイルにコマンドを定義します
{
"name": "next-graphql-todo-app",
"version": "0.1.0",
"private": true,
+ "prisma": {
+ "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
+ },
...
}
最後に、次のコマンドを実行します
npx prisma db seed
Environment variables loaded from .env
Running seed command `ts-node --compiler-options {"module":"CommonJS"} prisma/seed.ts` ...
prisma:query BEGIN
prisma:query INSERT INTO "public"."tasks" ("done","created_at","title","updated_at") VALUES ($1,$2,$3,$4), ($5,$6,$7,$8), ($9,$10,$11,$12)
prisma:query COMMIT
🌱 The seed command has been executed.
これで初期データを投入することができました
Prisma Studio
Prismaはデータベースのデータをブラウザで確認・操作できるツールであるPrimsa Studioを提供しています(便利!)
次のコマンドを実行すると、ブラウザでPrisma Studioが立ち上がります
npx prisma studio
tasksテーブルのデータを見ると、先ほど投入した初期データが保存されていることがわかります
GraphQL APIを追加する
GraphQL APIのためのエンドポイントを追加して、GraphQLサーバーを起動します
GraphQLサーバーに必要なGraphQLスキーマとresolverを定義していきます
手順
- GraphQLスキーマを定義する
- resolverを定義する
- GraphQL APIのためのエンドポイントを追加する
- Prismaを使ってクエリを実行する
GraphQLスキーマを定義する
まずは必要なパッケージをインストールしておきます
npm install graphql apollo-server-micro micro-cors
graphql/schema.ts
ファイルにGraphQLスキーマを定義します
import { gql } from 'apollo-server-micro'
export const typeDefs = gql`
type Task {
id: Int
title: String
done: Boolean
}
type Query {
tasks: [Task]!
}
`
ここでは以下の2つを定義しています
- Taskオブジェクトとそのフィールド(カラム)
- tasksクエリ
resolverを定義する
graphql/resolvers.ts
ファイルにクエリの実行結果を返す関数を定義します
今は仮データを直接記述しています
後からDBのデータを参照するように変更します
export const resolvers = {
Query: {
tasks: () => {
return [
{
id: 1,
title: 'sample task 1',
done: true,
},
{
id: 2,
title: 'sample task 2',
done: true,
},
{
id: 3,
title: 'sample task 3',
done: false,
},
]
}
}
}
エンドポイントを追加する
Next.jsのAPI Routesを利用してGraphQL APIのエンドポイントを追加します
pages/api/graphql.ts
ファイルを作成します
import { ApolloServer } from 'apollo-server-micro'
import { typeDefs } from '../../graphql/schema'
import { resolvers } from '../../graphql/resolvers'
import Cors from 'micro-cors'
const cors = Cors()
const apolloServer = new ApolloServer({ typeDefs, resolvers })
const startServer = apolloServer.start()
export default cors(async function handler(req, res) {
if (req.method === 'OPTIONS') {
res.end()
return false
}
await startServer
await apolloServer.createHandler({
path: '/api/graphql',
})(req, res)
})
export const config = {
api: {
bodyParser: false,
}
}
new ApolloServer
に先ほど定義したGraphQLスキーマとresolverを指定して、インスタンスを作成します
リクエストを受け取ったらstartServer
とapolloServer.createHandler
を実行して、GraphQLを扱えるようにします
ApolloServerについての詳細は次のページをご覧ください
また、CORSを設定して、Apollo Studioを使えるようにしています
Apollo Studioについては後で触れますが、CORSの設定をしないとこのツールからGraphQLへのリクエストが失敗してしまいます
最後のconfig
はbodyParserのデフォルト設定をfalseに変更し、GraphQLを扱えるようにしています
Apollo Studio
サーバを起動して http://localhost:3000/api/graphql を開くと、Apollo Studioが起動します
このツールを使うとブラウザでGraphQLのスキーマ定義やクエリのレスポンスを確認することができます
tasks
クエリを実行すると、resolverで定義したレスポンスが返ってくることがわかります
Prismaでクエリを実行する
resolverでPrismaを使うようにして、データベースからデータを取得するように変更します
まず、resolverがPrismaClientにアクセスし、データベースにクエリを送信できるようにするために、contextを作成します
import { PrismaClient } from '@prisma/client'
import { prisma } from '../lib/prisma'
export type Context = {
prisma: PrismaClient
}
export async function createContext(): Promise<Context> {
return {
prisma,
}
}
ApolloServerにcontextを指定します
import { ApolloServer } from 'apollo-server-micro'
import { typeDefs } from '../../graphql/schema'
import { resolvers } from '../../graphql/resolvers'
import Cors from 'micro-cors'
+ import { createContext } from '../../graphql/context';
const cors = Cors()
const apolloServer = new ApolloServer({
typeDefs,
resolvers,
+ context: createContext,
})
const startServer = apolloServer.start()
// ...
}
これでresolverがPrismaClientを使えるようになりました
resolverの関数がPrismaの実行結果を返すように変更します
export const resolvers = {
Query: {
tasks: (_parent, _args, ctx) => {
return ctx.prisma.task.findMany()
},
},
}
ctx
はcontextに指定した値を返します
ctx.prisma
はPrismaClientであるので、Prismaを使ってデータベースからタスク一覧を取得することができます
_parent
や_args
はここでは使用していませんが、詳しくは次のページをご覧ください
Apollo Studioでtasksクエリを実行すると、Prismaを経由してPostgreSQLに保存されているデータを返すようになります
Nexusの導入
GraphQL Nexusを使ってGraphQLスキーマを定義するように変更していきます
Nexusを使うことには次のような利点があります
- GraphQLのSDL(Schema Definition Language)ではなく、コードファースト(JavaScript/TypeScript)でスキーマを定義することができる
- SDLではschemaとresolverの定義が分離していたが、Nexusでは2つを同じファイルで定義することができる
- SDLファイルと型定義を自動生成してくれる
- エディタで補完が効く
詳しくは次のページをご覧ください
Nexusでスキーマを定義する
graphql/types/Task.ts
ファイルを作成し、改めてNexusでスキーマ定義を記述します
import { objectType, extendType } from 'nexus'
export const Task = objectType({
name: 'Task',
definition(t) {
t.nonNull.int('id')
t.nonNull.string('title')
t.nonNull.boolean('done')
},
})
export const TasksQuery = extendType({
type: 'Query',
definition(t) {
t.nonNull.list.field('tasks', {
type: 'Task',
resolve(_parent, _args, ctx) {
return ctx.prisma.task.findMany()
},
})
},
})
objectType
でオブジェクトを定義し、extendType
でQueryとMutationを定義します
ここではTaskオブジェクトと、tasksクエリを定義しています
resolve
はgraphql/resolvers.ts
で書いていたように、クエリを実行する処理をここに書きます
オブジェクトごとに分けたファイルを集約するためのgraphql/types/index.ts
も作っておきます
export * from './Task'
Nexusに置き換える
まず、Nexusのライブラリをインストールします
npm install nexus
次に、graphql/schema.ts
にはGraphQLのSDLでスキーマを定義していましたが、このファイルを丸ごと書き換えます
import { makeSchema } from 'nexus'
import { join } from 'path'
import * as types from './types'
export const schema = makeSchema({
types,
outputs: {
typegen: join(process.cwd(), 'node_modules', '@types', 'nexus-typegen', 'index.d.ts'),
schema: join(process.cwd(), 'graphql', 'schema.graphql'),
},
contextType: {
export: 'Context',
module: join(process.cwd(), 'graphql', 'context.ts'),
},
})
NexusのmakeSchemaに以下を指定します
-
types
: object typesの配列を指定する -
outputs
: Nexusが生成するファイルをどこに保存するかを指定する-
typegen
: 型定義ファイルをnode_modules/@types/nexus-typegen/index.d.ts
に生成する設定 -
schema
: GraphQL SDLファイルをgraphql/schema.graphql
に生成する設定
-
-
contextType
:graphql/context.ts
ファイルを指定する
最後に、pages/api/graphql.ts
を変更します
import { ApolloServer } from 'apollo-server-micro'
- import { typeDefs } from '../../graphql/schema'
- import { resolvers } from '../../graphql/resolvers'
+ import { schema } from '../../graphql/schema'
import Cors from 'micro-cors'
import { createContext } from '../../graphql/context';
const cors = Cors()
const apolloServer = new ApolloServer({
- typeDefs,
- resolvers,
+ schema,
context: createContext,
})
const startServer = apolloServer.start()
// ...
}
スキーマ定義とresolverを指定していたところを、Nexusのschemaを指定するようにしました
Apollo Studioでtasksクエリを実行すると、Nexusで定義したスキーマとresolverに従ってレスポンスが返されるようになっていることを確認できます
これでGraphQLを使うための環境を構築することができました
ApolloClientインスタンスの作成
クライアント側でGraphQLクエリを実行するためにApolloClientを使用します
まず、次のパッケージをインストールします
npm install @apollo/client
次に、lib/apollo.ts
でApolloClientのインスタンスを作成します
import { ApolloClient, InMemoryCache } from '@apollo/client'
const apolloClient = new ApolloClient({
uri: 'http://localhost:3000/api/graphql',
cache: new InMemoryCache(),
})
export default apolloClient
uri
にはGraphQLエンドポイントを指定します
cache
にはInMemoryCacheのインスタンスを指定することで、クエリの結果をキャッシュに保存してくれるようになります
pages/_app.tsx
を次のように変更して、各コンポーネントからApolloClientを使用してGraphQLクエリを送ることができるようにします
import type { AppProps } from 'next/app'
+ import { ApolloProvider } from '@apollo/client'
+ import apolloClient from '../lib/apollo'
function MyApp({ Component, pageProps }: AppProps) {
return (
+ <ApolloProvider client={apolloClient}>
<Component {...pageProps} />
+ </ApolloProvider>
)
}
export default MyApp
シンプルTODOアプリの実装
useQueryでタスクデータを取得する
ここからやっとTODOアプリの実装になります
まず、タスク一覧を取得するところから始めます
tasksクエリを実行して結果を一覧表示するコードを以下に示します
import type { NextPage } from 'next'
import Head from 'next/head'
import { gql, useQuery } from '@apollo/client'
const AllTasksQuery = gql`
query {
tasks {
id
title
done
}
}
`
const Home: NextPage = () => {
const { data, loading, error } = useQuery(AllTasksQuery)
if (loading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
return (
<div>
<Head>
<title>Create Next App</title>
<meta name='description' content='Generated by create next app' />
<link rel='icon' href='/favicon.ico' />
</Head>
<main>
<h1>SIMPLE TASK LIST</h1>
<ul>
{data.tasks.map((task) => (
<li key={task.id}>
<p>{task.title}</p>
</li>
))}
</ul>
</main>
</div>
)
}
export default Home
useQuery
hookを使ってクエリを実行することができます
以下の返り値を受け取ります
-
loading
: データ取得が完了したらtrueになるbooleanの値です -
error
: クエリ実行時にエラーが生じた場合に値が入ります -
data
: クエリのレスポンスが入ります
このように記述することでデータの取得・表示を行うことができます
また、データの操作を行うクエリは、後述するuseMutation
hookを使います
データを扱う準備が整ったので、あとはTODOアプリの画面と機能を作っていくだけです
Chakra UIのセットアップ
UIライブラリとしてChakra UIを使用するのでインストールします
npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^4 @chakra-ui/icons
ChakraProviderでAppコンポーネントを囲んでおきます
import type { AppProps } from 'next/app'
import { ApolloProvider } from '@apollo/client'
import apolloClient from '../lib/apollo'
+ import { ChakraProvider } from '@chakra-ui/react'
function MyApp({ Component, pageProps }: AppProps) {
return (
<ApolloProvider client={apolloClient}>
+ <ChakraProvider>
<Component {...pageProps} />
+ </ChakraProvider>
</ApolloProvider>
)
}
export default MyApp
タスク一覧画面
前のキャプチャのタスク一覧画面をChakra UIで表示します
useQueryを使う方法は変わりませんのでコードは省略します
components/TaskList.tsx
import { gql, useQuery } from '@apollo/client'
import { Checkbox, List, ListItem } from '@chakra-ui/react'
export const AllTasksQuery = gql`
query {
tasks {
id
title
done
}
}
`
const TaskList: React.FC = () => {
const { data, loading, error } = useQuery(AllTasksQuery)
if (loading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
return (
<List>
{data.tasks.map(task => (
<ListItem key={task.id}>
<Checkbox colorScheme='teal' isChecked={task.done}>
{task.title}
</Checkbox>
</ListItem>
))}
</List>
)
}
export default TaskList
pages/index.tsx
import type { NextPage } from 'next'
import Head from 'next/head'
import TaskList from '../components/TaskList'
import { Container, Heading, Stack } from '@chakra-ui/react'
const Home: NextPage = () => {
return (
<div>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<Container my='32px'>
<Stack spacing='32px'>
<Heading>TASK LIST</Heading>
<TaskList />
</Stack>
</Container>
</main>
</div>
)
}
export default Home
タスク追加フォーム
タスク追加フォームを表示して、タスクの追加を行えるようにします
そのために必要な、タスクの作成を行うMutationをgraphql/Task.ts
に新しく定義します
import { objectType, extendType, stringArg, nonNull } from 'nexus'
// ...
export const CreateTaskMutation = extendType({
type: 'Mutation',
definition(t) {
t.nonNull.field('createTask', {
type: 'Task',
args: {
title: nonNull(stringArg()),
},
resolve(_parent, args, ctx) {
return ctx.prisma.task.create({
data: {
title: args.title
}
})
}
})
}
})
クライアントでMutationを実行するには、useMutation
hookを使います
タスクの作成を行うmutationは以下のように書くことで実行することができます
import { gql, useMutation } from '@apollo/client'
const CreateTaskMutaiton = gql`
mutation CreateTask($title: String!) {
createTask(title: $title) {
id
title
done
}
}
`
const [createTask, { error }] = useMutation(CreateTaskMutation)
// mutationの実行
createTask({
variables: {
title: title,
},
})
useMutationは2つのタプルを返します
1つ目のcreateTask
はmutation functionであり、これを呼び出すことで実際にmutationを実行します
2つ目はオブジェクトで、errorやloadingなどのステータスを表すデータを返します
createTask
を呼び出すときはvariables
に引数を指定することができます
createTask mutationを実行すると新しいタスクが作成されます
しかし、このときタスク一覧結果には表示されません
mutationを実行したあと、特定のqueryを再取得するには、次のようにuseMutationの第2引数にrefetchQueries
を指定します
import { AllTasksQuery } from './TaskList'
const [createTask, { error }] = useMutation(CreateTaskMutation, {
refetchQueries: [AllTasksQuery],
})
components/TaskAddForm.tsx
フォームのライブラリであるformikを使うのでインストールしておきます
npm install formik --save
import { Formik, Field, Form } from 'formik'
import { Stack, FormControl, Input, Button } from '@chakra-ui/react'
import { gql, useMutation } from '@apollo/client'
import { AllTasksQuery } from './TaskList'
const CreateTaskMutaiton = gql`
mutation CreateTask($title: String!) {
createTask(title: $title) {
id
title
done
}
}
`
const TaskAddForm: React.FC = () => {
const [createTask, { error }] = useMutation(CreateTaskMutaiton, {
refetchQueries: [AllTasksQuery],
})
const handleSubmit = (title: string, resetForm: () => void) => {
if (!title) return
createTask({
variables: {
title: title,
},
})
resetForm()
}
if (error) return <p>Error: {error.message}</p>
return (
<Formik
initialValues={{ title: '' }}
onSubmit={(value, actions) => handleSubmit(value.title, actions.resetForm)}
>
<Form>
<Stack direction='row'>
<Field name='title'>
{({ field }) => (
<FormControl>
<Input {...field} id='title' type='text' placeholder='Add task' />
</FormControl>
)}
</Field>
<Button colorScheme='teal' type='submit'>
Submit
</Button>
</Stack>
</Form>
</Formik>
)
}
export default TaskAddForm
pages/index.tsx
import type { NextPage } from 'next'
import Head from 'next/head'
import TaskList from '../components/TaskList'
import { Container, Heading, Stack } from '@chakra-ui/react'
+ import TaskAddForm from '../components/TaskAddForm'
const Home: NextPage = () => {
return (
<div>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<Container my='32px'>
<Stack spacing='32px'>
<Heading>TASK LIST</Heading>
+ <TaskAddForm />
<TaskList />
</Stack>
</Container>
</main>
</div>
)
}
export default Home
完成品
残りタスクの削除機能、完了チェック機能を加えたら完成です
query, mutationの実行方法は変わらないためコードは省略します
最終的なコードは次のリポジトリにありますので、もし気になればご参照ください
参考にしたサイト
Discussion