🍎

Nextjs(custom server) +GraphQL+postgresqlでRSCを試してみる

2024/08/12に公開

前提

このあたりの技術要素を扱います。

  • Nextjs
    • CustomServer
    • AppRouter
  • Nodejs
    • Express
  • GraphQL
    • Apollo
  • Neon(Postgresql)
  • DrizzleORM

NextjsAppRouterCustomServerとしています。RSC(React Server Component)を試してみたいのと、CustomServerはGraphQLサーバーとクライアント両方をNextjsに持たせてみたいという意図で選定しました。

データベースはNeon(Postgresql)を選定しました。FreePlanで個人利用する分にはこちらが最適そうだったので選びました。
https://egashira.dev/blog/free-rdb-services-2023

ORMDrizzleを選定しました。GitHubリポジトリのStarスターの伸び具合を表示するサイトを見ると将来性がありそうだったので選定しました。
https://star-history.com/#drizzle-team/drizzle-orm&Date

やりたいこと

Nextjs(custom server)でNeon(postgresql)に接続してみます!。

手順

1.Nextjsの初期設定

npxで初期化していきます。
typescript,tailwind,AppRouterを選択します。

npx create-next-app@latest .
create-next-app@14.2.5
Ok to proceed? (y) y
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
Creating a new Next.js app in /home/****

http://localhost:3000が起動することを確認したら次に進みます

npm run dev

2.CustomServerを設定する

NextjsはNodejs上で動作していますが、Nodejs部分をより自由にカスタマイズできるようにCustomServerの設定をします。

ts-node,@types/expressをインストールします。

npm i -D ts-node @types/express

expressもインストールします。

npm i express

server/index.tsを作成します。
expressを起動し、'*'でnextjs用のハンドラオブジェクトをreturnするようにします。
※これでnextjsコンテンツを返すようになります。

import type { Request, Response } from 'express'
import express from 'express'
import next from 'next'

const dev = process.env.NODE_ENV !== 'production'
const nextApp = next({ dev, isNodeDebugging: true })
const handle = nextApp.getRequestHandler()
const port = process.env.PORT || 8080

async function main() {
  try {
    await nextApp.prepare()
    const app = express()

    app.use(express.json())
    app.use(express.urlencoded({ extended: true }))
    app.all('*', (req: Request, res: Response) => {
      return handle(req, res)
    })

    app.listen(port, (err?: unknown) => {
      if (err) throw err
      console.log(`> Ready on localhost:${port} - env ${process.env.NODE_ENV}`)
    })
  } catch (e) {
    console.error(e)
    process.exit(1)
  }
}

main()

次にルートにtsconfig.build.jsonを作成します。
build時にこちらの追加オプションが必要になります。

{
    "extends": "./tsconfig.json", // tsconfig.jsonの設定を継承する
    "compilerOptions": {
      "module": "CommonJS", // Next.jsとExpressの両方を連携させるために、commmonjsを利用する
      "outDir": "dist", // ビルドファイルの出力先
      "isolatedModules": false,
      "noEmit": false // Next.jsはBebelを使用してTypeScriptをコンパイルするので、TSコンパイラはjsを出力しない。設定を上書きする。
    },
    "include": ["server"], // TSコンパイラにserverディレクトリのみをコンパイル対象として認識させる。
    "exclude": ["node_modules"]
  }

最後にpackage.jsonのscriptsと.vscode/launch.jsonを以下のように修正して終わりです。launch.jsonはデバッグ実行のためだけの設定です。

package.json

  "scripts": {
    "dev": "ts-node --project tsconfig.build.json server/index.ts",
    "build:next": "next build",
    "build:server": "tsc --project tsconfig.build.json",
    "build": "npm run build:next && npm run build:server",
    "start": "next start",
    "lint": "next lint"
  },

launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Next.js: debug server-side",
            "type": "node-terminal",
            "request": "launch",
            "command": "npm run dev",
            "resolveSourceMapLocations": [
                "${workspaceFolder}/**",
                "!**/node_modules/**"
            ]
          },
    ]
}

以上でCustomServerの設定は終わったのでデバッグしてみましょう

vscodeでserver/index.tsのapp.allのところにブレークポイントを張って
デバッグ実行して止まることを確認しましょう。

3.Nextjs(サーバー側)からNeon(Postgresql)に接続してみる

Neonを利用するにはこちらの解説に沿ってアカウント登録等を行っていきます。
https://moldspoon.jp/blog/posts/how-to-start-neon

環境変数を利用するためにdotenvを導入します。

npm i -D dotenv

server/index.tsで以下のようにdotenvをimportすることで.envファイルを環境変数として読み込めるようにします。

import 'dotenv/config'

.envには以下DB接続文字列をNeon(Postgresql)からコピペしてきます。

DATABASE_URL=postgresql://******:******@*******.ap-southeast-1.aws.neon.tech/********?sslmode=require

DBアクセスするためのパッケージとORMをインストールします。

npm i @neondatabase/serverless drizzle-orm

※react:^18 だとなぜかdrizzle-ormでdependencyエラーとなったので下記18.3.1を入れなおして上記を再実行しました。

npm i react@^18.3.1 react-dom@^18.3.1

DBアクセスするためのコードを書いていきます。
server/db/index.tsに以下記述します。

import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'
const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql)

server/index.ts でdbインポートして接続できているか試しに確認してみます。

import type { Request, Response } from 'express'
import express from 'express'
import next from 'next'
import 'dotenv/config'
import { db } from './db'

const dev = process.env.NODE_ENV !== 'production'
const nextApp = next({ dev, isNodeDebugging: true })
const handle = nextApp.getRequestHandler()
const port = process.env.PORT || 8080

async function main() {
  try {
    await nextApp.prepare()
    const app = express()

    const dbInstance = await db
    console.log(dbInstance)

    app.use(express.json())

セッションは作られてそうです。

DBに接続できました。

4.Neon(Postgresql)にテーブル作成

DrizzleORMを導入したので、ローカルにあるスキーマからテーブル作成が可能となります。
Drizzleのスキーマを定義していきます。

server/db/model/index.tsにスキーマを定義します。
ToDoテーブルを作成します。

import {
    pgTable,
    varchar,
  } from 'drizzle-orm/pg-core'
  
  export const user = pgTable('todo', {
    taskId: varchar('taskId', { length: 256 }).primaryKey(),
    contents: varchar('contents', { length: 256 }),
  })

DrizzleがローカルからNeon(Postgresql)に対してSQL文を発行できるようにconfigを設定していきます。

まずはDrizzle-Kitをインストールします。

npm i -D drizzle-kit

次にdrizzle.config.tsをルートに作成します。
その他のDBの設定はこちらに記載があります。

import type { Config } from 'drizzle-kit'
import * as dotenv from 'dotenv'

dotenv.config()

export default {
  schema: './server/db/model/index.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL || '',
  },
  verbose: true,
  strict: true,
} satisfies Config

package.jsonにdrizzleコマンドを追加します。

  "scripts": {
    "dev": "ts-node --project tsconfig.build.json server/index.ts",
    "build:next": "next build",
    "build:server": "tsc --project tsconfig.build.json",
    "build": "npm run build:next && npm run build:server",
    "start": "next start",
    "lint": "next lint",
    "drizzle:generate": "drizzle-kit generate --config=./drizzle.config.ts",
    "drizzle:push": "drizzle-kit push --config=./drizzle.config.ts",
    "drizzle:introspect": "drizzle-kit introspect --config=./drizzle.config.ts"
  },
  • ①generateはローカル(drizzle.config.tsでoutに指定したフォルダ)にSQL文を作成します。
  • ②pushは生成したSQL文をDBに反映します。
  • ③introspectはDBからSQL文を生成します。

①②を順番に実行することでスキーマで指定したToDoテーブルをDBに反映することができます。実行してみましょう。

npm run drizzle:generate

> bookzero@0.1.0 drizzle:generate
> drizzle-kit generate --config=./drizzle.config.ts

Reading config file '/home/hiro/bookzero/drizzle.config.ts'
1 tables
todo 2 columns 0 indexes 0 fks

[] Your SQL migration file ➜ drizzle/0000_thin_boom_boom.sql 🚀

SQLが作成されています。

次にpushします。
※すでにDBに他のテーブルがneonに存在している場合は、introspectを行って作成されたスキーマをローカルコードに反映してからgenerate,pushするようにしましょう。

npm run drizzle:push
[] Changes applied

Neonを確認するとテーブルが作成されていることがわかります。

5.バックエンドGraphQLの設定

GraphQLサーバーとして@apollo/serverをインストールします。

npm i @apollo/server

ApolloServerのインスタンスに必要な情報を定義していきます。
server/graphql/index.tsに作成します。
apolloServerは、

  • typeDefs:型
  • resolver:解決(どのような値を返すかプログラムする)
    をinputとしますので以下のように作成します。
import type { BaseContext } from '@apollo/server'
import { ApolloServer } from '@apollo/server'
import type { NeonHttpDatabase } from 'drizzle-orm/neon-http'
import ToDo from './model/todo'

class GraphQL {
  private todo: ToDo

  constructor(db: NeonHttpDatabase<Record<string, never>>) {
    this.todo = new ToDo(db)
  }

  private typeDefs = `
  type Todo {
    taskId: String!
    contents: String
  }

  type Query {
    getTodos: [Todo]
  }
  `
  private resolvers = {
    Query: {
      getTodos: async (root: unknown, {}, {}) => {
        return this.todo.getToDos()
      },
    },
  }

  public apolloServer = new ApolloServer<BaseContext>({
    typeDefs: this.typeDefs,
    resolvers: this.resolvers,
  })
}
export default GraphQL

ToDoモデルを作ってDBの値を取得するようにします。

server/graphql/model/todo.ts

import type { NeonHttpDatabase } from 'drizzle-orm/neon-http'
import { todo } from '../../db/model'

export type GetTaskInputType = {
  input: {
    taskId: string
  }
}

class ToDo {
  private db

  constructor(db: NeonHttpDatabase<Record<string, never>>) {
    this.db = db
  }

  getToDos = async () => {
    const res = await this.db
      .select()
      .from(todo)
    return res
  }
}

export default ToDo

ExpressのミドルウェアにGraphQLを起動するように追加します。

server/index.ts

import type { Request, Response } from 'express'
import { expressMiddleware } from '@apollo/server/express4'
import express from 'express'
import next from 'next'
import 'dotenv/config'
import { db } from './db'
import GraphQL from './graphql'

const dev = process.env.NODE_ENV !== 'production'
const nextApp = next({ dev, isNodeDebugging: true })
const handle = nextApp.getRequestHandler()
const port = process.env.PORT || 8080

async function main() {
  
  try {
    /**
     * GraphQLクラス定義
     */
    const graphQl = new GraphQL(db)
    await graphQl.apolloServer.start()

    await nextApp.prepare()
    const app = express()

    app.use(express.json())
    app.use(express.urlencoded({ extended: true }))
    /**
     * GraphQL(ApolloServer)のミドルウェア
     */
    app.use(
      '/graphql',
      expressMiddleware(graphQl.apolloServer),
    )
    app.all('*', (req: Request, res: Response) => {
      return handle(req, res)
    })

    app.listen(port, (err?: unknown) => {
      if (err) throw err
      console.log(`> Ready on localhost:${port} - env ${process.env.NODE_ENV}`)
    })
  } catch (e) {
    console.error(e)
    process.exit(1)
  }
}

main()

この状態でデバッグを起動したら
http://localhost:8080/graphqlを参照してみましょう。

以下が表示されるはずです。
クエリを実行しても空の配列が取得されるのはテーブルにレコードがないからです。

2レコード追加しました。

クエリを実行したら2レコード表示されていました。

6.フロントエンドGraphQLの設定

下準備としてgraphql-codegenの設定をしていきます。

graphql-codegen:GraphQL Code Generatorは、GraphQLスキーマからコードを生成するツールです。

必要なパッケージを追加します。

npm i -D @graphql-codegen/cli @graphql-codegen/client-preset @graphql-codegen/introspection

次にルートにcodegen.tsを作成します。
schemaにはgraphqlのエンドポイント、generatesには生成される型のパスを指定します。
フロントエンドはsrc配下なのでそちらを指定します。

import type { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  overwrite: true,
  schema: 'http://localhost:8080/graphql',
  documents: '**/*.{gql,graphql}',
  generates: {
    'src/graphql/generated/': {
      preset: 'client',
      plugins: [],
    },
    './graphql.schema.json': {
      plugins: ['introspection'],
    },
  },
}

export default config

次にスキーマファイルを作成します。
src/graphql/todo.query.gql

query GetTodos {
  getTodos {
    taskId
    contents
  }
}

package.jsonのscriptsにcodegenを追加します。

  "scripts": {
    "dev": "ts-node --project tsconfig.build.json server/index.ts",
    "build:next": "next build",
    "build:server": "tsc --project tsconfig.build.json",
    "build": "npm run build:next && npm run build:server",
    "start": "next start",
    "lint": "next lint",
    "drizzle:generate": "drizzle-kit generate --config=./drizzle.config.ts",
    "drizzle:push": "drizzle-kit push --config=./drizzle.config.ts",
    "drizzle:introspect": "drizzle-kit introspect --config=./drizzle.config.ts",
    "codegen": "graphql-codegen --config codegen.ts"
  },

実行してみましょう。
まずはnpm run devでgraphqlを起動した状態にしておいて、以下を実行します。

npm run codegen
> ****@0.1.0 codegen
> graphql-codegen --config codegen.ts

✔ Parse Configuration
✔ Generate outputs

成功したら型定義ファイルが生成されているはずです。

7.フロントエンドからGraphQLを呼び出す

apollo clientをインストールします

npm i @apollo/client

RSCでapollo clientを試したいので以下インストールします

npm i @apollo/experimental-nextjs-app-support

apollo clientを呼び出すtsを作成します。

src/lib/apollo/apolloClient.ts

import { HttpLink } from '@apollo/client'
import {
  ApolloClient,
  InMemoryCache,
  registerApolloClient,
} from '@apollo/experimental-nextjs-app-support'

export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: `http://localhost:8080/graphql`,
      fetchOptions: { cache: 'no-store' },
    }),
  })
})

page.tsxで動的にToDoコンポーネントを呼び出し、
ToDoコンポーネントではGraphQLリクエストするようにします。

なぜか export const dynamic = 'auto' としてしまうと
動的レンダリング判定とならずエラーとなってしまいます・・・!

src/app/page.tsx

export const dynamic = 'force-dynamic'

import { ToDo } from '@/app/components/ToDo'
import { Suspense } from 'react'

export default function Home() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
        <Suspense fallback={<>loading</>}>
          <ToDo />
        </Suspense>
      </div>
    </main>
  )
}

src/app/components/ToDo.tsx

import { GetTodosDocument } from '@/graphql/generated/graphql'
import { getClient } from '@/lib/apollo/apolloClient'

export async function ToDo() {
  const { data } = await getClient().query({ query: GetTodosDocument })
  return (
    <div>
      <div>data:{JSON.stringify(data)}</div>
    </div>
  )
}

ローカル実行時、ローカルビルド時、本番ビルド時すべて問題ないようにpackage.jsonを修正します。

  "scripts": {
    "dev": "ts-node --project tsconfig.build.json server/index.ts",
    "build:server": "tsc --project tsconfig.build.json",
    "build:next": "next build",
    "build": "npm run build:server && npm run build:next",
    "local": "NODE_ENV=development ts-node --project tsconfig.build.json dist/index.js",
    "start": "NODE_ENV=production ts-node --project tsconfig.build.json dist/index.js",
    "lint": "next lint",
    "drizzle:generate": "drizzle-kit generate --config=./drizzle.config.ts",
    "drizzle:push": "drizzle-kit push --config=./drizzle.config.ts",
    "drizzle:introspect": "drizzle-kit introspect --config=./drizzle.config.ts",
    "codegen": "graphql-codegen --config codegen.ts"
  },

ビルド&実行してみます。

npm run build
  ▲ Next.js 14.2.5
  - Environments: .env

   Creating an optimized production build ...
 ✓ Compiled successfully
 ✓ Linting and checking validity of types    
 ✓ Collecting page data    
 ✓ Generating static pages (5/5)
 ✓ Collecting build traces    
 ✓ Finalizing page optimization    

Route (app)                              Size     First Load JS
┌ ƒ /                                    17.7 kB         105 kB
└ ○ /_not-found                          871 B          87.9 kB
+ First Load JS shared by all            87 kB
  ├ chunks/23-0400b34f6f7b0912.js        31.5 kB
  ├ chunks/fd9d1056-d26b0eab797fab5a.js  53.6 kB
  └ other shared chunks (total)          1.86 kB


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

動的(Dynamic)にbuildされてそうです。

npm run start

loading表示後に以下Jsonが表示されました。

感想

AppRouterで動的なページを強制するには'use client'を削除すればいいだけだと思っていましたが
export const dynamic = 'force-dynamic'としなければいけなかったみたいで少しハマりました・・・。NextjsをCustomServerで設定するメリットは自然な形でNodejs×Express×GraphQLを導入できるところかなぁと思っています。

関連

https://zenn.dev/webshoten/articles/2d07b4467805ff

https://zenn.dev/webshoten/articles/cf32f5aa915964

リポジトリ

リポジトリ
https://github.com/webshoten/nextjs_customserver_graphql

Discussion