7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

実戦!Next.js と StepZen で作る GraphQL モダンアプリ開発

Last updated at Posted at 2023-08-20

はじめに

本資料では、ハンズオンを想定し、Next.js と StepZen を利用し todo アプリを開発することで、GraphQL を用いた モダンなWeb アプリの構築方法を学びます。また、GraphQL を用いる際の技術選定の参考になればと思います。StepZen を用いない場合でも、 Apollo ClientやGraphQL Code Generator を用いた Next.js アプリの参考になるかと思います。

対象者

  • Web サービスを迅速に立ち上げたい方
  • GraphQL によるモダンなアプリ開発に関心がある方
  • Web サービスの技術選定を検討中の方

前提条件

参加者は、Web アプリケーション開発に関する基本的な知識を有していることが前提となります。

本ハンズオンの内容

すること

  • Next.js と StepZen での todo アプリ作成
  • StepZen を利用しての GraphQL API サーバーの高速構築
  • GraphQL Code Generator でのクライアントコードの自動生成
  • Apollo Client を使用した GraphQL のクライアント実装

しないこと

  • React/Next.js や StepZen の詳細説明
  • 認証・認可やセキュリティ関連の実装
  • エラーハンドリングの実装
  • Vercel を利用した Web アプリのデプロイ

StepZenについては、以前書いた記事(GraphQL/StepZen ハンズオン資料 #1 - Qiita)もご参照ください。

作成するアプリケーション

このハンズオンを完了すると、以下の機能を持った todo アプリケーションが完成します。

  • Todo 一覧表示: すべての todo アイテムを一覧できるページ。
  • Todo 追加機能: 新しい todo アイテムを追加できるフォーム。
  • Todo 編集機能: 既存の todo アイテムの完了・未完了を変更する機能。
  • Todo 削除機能: 任意の todo アイテムを削除することができるボタン。
  • データベースとの連携: ユーザーが入力した todo アイテムは PostgreSQL に保存され、アプリケーションの再起動後もデータは維持されます。

完成アプリケーションのイメージ

完成後のアプリケーションは以下のような画面になります。
image.png

また、ソースコードは 下記 GitHub リンク からアクセスできます。参考にしてください。
optimisuke/todoapp-nextjs-stepzen: Next.js と StepZen を使った todo アプリ

アプリケーションの構成

アプリケーションの構成は以下の図のようになります。
Screenshot 2023-08-21 at 22.03.43.png

使用する技術・サービス

  • 開発言語・フレームワーク: Node.js, Next.js, React, TypeScript
  • データベース: PostgreSQL
  • ツール & ライブラリ: GraphQL Code Generator, Apollo Client, MUI
  • マネージドサービス: StepZen (RDB や REST API を GraphQL API に変換), Neon (PostgreSQL のマネージドサービス)

手順概要

以下の手順でハンズオンを進めます。

  1. Node.js インストール
  2. Next.js/React を使った todo アプリ画面構築
  3. PostgreSQL/Neon を使った todo データベース構築
  4. StepZen を使った GraphQL API サーバー構築
  5. GraphQL Code Generator を使った クライアントコード自動生成
  6. Next.js/React からの GraphQL API 呼び出し

手順詳細

1. Node.js インストール

下記サイトからインストーラーをダウンロードしてインストールしてください。

ダウンロード | Node.js

他にも、VoltaHomebrewを用いてインストールする方法もあります。ご自身の環境に合わせてインストールしてください。

2. Next.js/React を使った todo アプリ画面構築

次に、Next.js/React を用いてアプリ画面を構築します。ここでは、バックエンド API はまだ呼び出しません。

まず、作業フォルダにて、create-next-appを用いて環境を構築します。

npx create-next-app@latest --typescript

対話的に質問が発生しますが、デフォルトの設定で問題ありません。
作業フォルダにmy-appフォルダが作成されますので、フロントエンド(Next.js/React)の作業をする際は、my-appフォルダにてコマンドを実行ください。
動作確認のため、下記コマンドを実行します。

working_directory
cd my-app
my-app
npm run dev

ブラウザにてhttp://localhost:3000/にアクセスすると、以下のような Next.js の初期画面が表示されますので、確認ください。npm run devコマンドは必要に応じて、Ctrl-Cで停止してください。

image.png

次に、今回使用するパッケージをインストールします。
主に MUI 関係のものを選択しています。

my-app
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material

次に、app フォルダを削除し、pages フォルダと components フォルダを作成します。
そして、以下の 4 ファイルを作成します。

  • pages/index.tsx
  • components/TodoList.tsx
  • components/TodoInput.tsx
  • components/TodoItem.tsx

4つのファイルの中身について、ここから説明していきます。
まず、トップページとして、下記ファイルでタイトルと TodoList コンポーネントを表示します。
MUI のコンポーネントを用いて見た目を調整しています。

my-app/pages/index.tsx
import { Typography, Container } from "@mui/material";
import React from "react";
import TodoList from "../components/TodoList";

const IndexPage: React.FC = () => {
  return (
    <Container maxWidth="sm">
      <Typography variant="h4" align="center" gutterBottom>
        ToDo List
      </Typography>
      <TodoList />
    </Container>
  );
};

export default IndexPage;

次に、TodoList コンポーネントでは、TodoInput コンポーネントと、TodoItem コンポーネントのリストを表示します。
また、task をステートとして管理し、追加、チェックボックスのトグル処理、削除の関数を記載します。
追加の関数は TodoInput コンポーネントに渡し、トグルと削除の関数は、TodoItem コンポーネントに渡します。

my-app/components/TodoList.tsx
import { List } from "@mui/material";
import React, { useState } from "react";
import TodoItem from "./TodoItem";
import TodoInput from "./TodoInput";

type Task = {
  task: string;
  completed: boolean;
};

const TodoList: React.FC = () => {
  const [tasks, setTasks] = useState<Task[]>([]);

  const addTask = (task: string) => {
    setTasks([...tasks, { task, completed: false }]);
  };

  const toggleTask = (index: number) => {
    const newTasks = [...tasks];
    newTasks[index].completed = !newTasks[index].completed;
    setTasks(newTasks);
  };

  const deleteTask = (index: number) => {
    const newTasks = [...tasks];
    newTasks.splice(index, 1);
    setTasks(newTasks);
  };

  return (
    <div>
      <TodoInput addTask={addTask} />
      <List>
        {tasks.map((task, index) => (
          <TodoItem
            key={index}
            task={task.task}
            completed={task.completed}
            toggleTask={() => toggleTask(index)}
            deleteTask={() => deleteTask(index)}
          />
        ))}
      </List>
    </div>
  );
};

export default TodoList;

TodoInput コンポーネントは、form を使って、追加処理を実施します。
また、TextField に入力した文字列をステートとして管理し表示できるようにします。

my-app/components/TodoInput.tsx
import { TextField, Button, Box } from "@mui/material";
import React, { useState } from "react";

type TodoInputProps = {
  addTask: (task: string) => void;
};

const TodoInput: React.FC<TodoInputProps> = ({ addTask }) => {
  const [task, setTask] = useState("");

  const submitTask = (e: React.FormEvent) => {
    e.preventDefault();
    if (task === "") return;
    addTask(task);
    setTask("");
  };

  return (
    <form onSubmit={submitTask}>
      <Box display="flex" justifyContent="center" gap="10px" p={2}>
        <TextField
          label="New task"
          variant="outlined"
          value={task}
          onChange={(e) => setTask(e.target.value)}
        />
        <Button type="submit" variant="contained" color="primary">
          Add
        </Button>
      </Box>
    </form>
  );
};

export default TodoInput;

TodoItem コンポーネントでは、チェックボックスと todo タスクのタイトル、削除アイコンを表示しています。

my-app/components/TodoItem.tsx
import {
  ListItem,
  ListItemText,
  IconButton,
  ListItemSecondaryAction,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";

type TodoItemProps = {
  task: string;
  completed: boolean;
  toggleTask: () => void;
  deleteTask: () => void;
};

const TodoItem: React.FC<TodoItemProps> = ({
  task,
  completed,
  toggleTask,
  deleteTask,
}) => {
  return (
    <ListItem>
      <IconButton edge="start" color="inherit" onClick={toggleTask}>
        {completed ? <CheckCircleOutlineIcon /> : <RadioButtonUncheckedIcon />}
      </IconButton>
      <ListItemText
        primary={task}
        style={{ textDecoration: completed ? "line-through" : "none" }}
      />
      <ListItemSecondaryAction>
        <IconButton edge="end" color="inherit" onClick={deleteTask}>
          <DeleteIcon />
        </IconButton>
      </ListItemSecondaryAction>
    </ListItem>
  );
};

export default TodoItem;

下記コマンドで動作確認します。

my-app
npm run dev

http://localhost:3000 に接続すると、以下のような画面が表示されると思います。

image.png

ぜひ、todoの追加や削除を試してみてください。ブラウザのメモリ上でタスクが保存されているため、リロードすると状態が消えてしまいますが、作成するtodo アプリの機能を確認することができます。次からは、データを永続化するために、データベースとGraphQL APIを作成していきます。
image.png

3. PostgreSQL/Neon を使った todo データベース構築

ここまでは、フロントエンドのみで Todo アプリを作成しました。次から、データベースを準備してデータを永続化できるようにセットアップしていきます。

Neon は PostgreSQL のマネージドサービスです。制限はありますが無料枠で非常に簡単に使えます。

下記手順に従って、データベースを作成していきます。

まず、トップページから画面のアカウントを作成します。
image.png

Github, Google Hasura のアカウントで Sign up できます。
image.png

Sign up後ログインすると以下のようなページが表示されます。
まず、プロジェクトを作成します。
image.png

image.png

作成後、接続情報が表示されるので、メモしてください。
image.png

接続情報はDashboardからも確認できます。
image.png

SQL EditorにSQLを書くことでデータベースを操作できます。
image.png

下記 SQL を用いてテーブルを作成します。先ほどのSQL Editorの画面にSQLをコピペして、Runを押すことでSQLを実行でいます。
今回、テーブルは基本的な情報のみとしました。また、id は UUID を使用することにしました。

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE todos (
  id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  completed BOOLEAN DEFAULT false NOT NULL,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

後ほどテーブルの中身を確認するため、データも入れておきます。先ほどと同様、SQL Editorを用いて以下のSQLを実行します。

INSERT INTO todos (title, completed)
VALUES ('新しいToDo', false);

また、update_at が自動更新されるようにトリガーを登録しておきます。
update_at の値をどこで生成するか、いくつかの選択肢がありますが、バックエンドサーバー(API サーバー)は StepZen で自動生成するためロジックを入れにくく、フロントエンド(クライアント)ではユーザー環境に依存する可能性があるので、データベースで更新することにしました。

CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_name_before_update
BEFORE UPDATE ON todos
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

Tablesのページで、テーブルが作成されていることとデータが入力されていることを確認できます。
image.png

これで、データベースの構築が完了しました。次は、StepZen を使ってデータベースを GraphQL API 化していきます。

4. StepZen を使った GraphQL API サーバー構築

StepZenはデータベース等を簡単にGraphQL APIサーバーとしてデプロイするクラウドサービスとなっています。ブラウザ上のダッシュボードとコマンドラインインターフェースを用いて操作します。最初にアカウントを作成とコマンドラインのインストールを行い、コマンドラインを使いながらGraphQL APIサーバーを構築していきます。

まず始めに、StepZen のウェブサイトにアクセスして、アカウントを作成しましょう。

image.png
image.png

次に、StepZen CLI をインストールします。
以下、Next.js/Reactのmy-appフォルダのとなりに、apiフォルダを作成して作業していきます。

working_directory
mkdir api
cd api

StepZen CLI を使用すると、ローカルマシンから StepZen サービスに直接アクセスできます。CLI をインストールするには、次の npm コマンドを実行します。(Node.jsがインストールされていることを前提としています。)-gをつけることでNodeのグローバルインストールとしています。インストールコマンドはどの場所で実行しても問題ありません。

npm install -g stepzen

もし、permission deniedを含むエラーが出た場合、フォルダのパーミッションの問題と考えられるのでsudo npm install -g stepzenを試してみてください。

インストール後、以下のコマンドを使って StepZen にログインします。ログインコマンドもどの場所で実行しても問題ありません。

stepzen login

下記プロンプトが出てくるので、それぞれStepZen Dashboardの鍵のアイコンの Account ページから取得したアカウント名と Admin Key を入力ください。

What is your account name?: ACCOUNT_NAME
What is your admin key?: ADMIN_KEY

image.png

これで StepZen のセッティングが完了しました。

次に、下記コマンドを実行し、GraphQL スキーマと StepZen ビルド用の構成ファイルを生成します。このコマンドはStepZen用のフォルダとして作ったapiフォルダにて実行していきます。

api
stepzen import postgresql

以下のように対話的に必要な項目を入力していきます。
StepZenのユーザー情報やエンドポイントの名前、Neonで確認した接続情報を入力していきます。Neonに記載のある接続情報は、postgres://username:password@host/databasenameとなっているので、質問に対してusername, password, host, databasenameをコピーして、それぞれの情報を記載ください。
また、schemaについての質問はdefaultにするためにブランクとし、最後の質問はYesにするためにyを入力しました。

api
$ stepzen import postgresql
? What would you like your endpoint to be called? api/todos

stepzen import postgresql - introspect a PostgreSQL database and extend your GraphQL schema with the types, queries and mutations for accessing it through a StepZen API.

? What is your host? xxx.example.com
? What is the username? optimisuke
? What is the password? [hidden]
? What is your database name? neondb
? What is your database schema (leave blank to use defaults)?
? Automatically link types based on foreign key relationships using @materializer
 (https://stepzen.com/docs/features/linking-types) Yes
Starting... done
Successfully imported postgresql data source into your GraphQL schema

次に、postgresql/index.graphqlを修正していきます。
insert する時に id や update_at が不要になるように修正します。
title は必須とし completed はオプションとしています。
completed が入っていなかった場合を考えてsqlの関数であるCOALESCEを使用しています。
以下に変更前と変更後の箇所を抜粋して記載します。

変更前(l. 54 周辺 type Mutation の中)

api/postgresql/index.graphql
  insertTodos(
    id: ID!
    completed: Boolean
    created_at: DateTime
    title: String!
    updated_at: DateTime
  ): Todos
    @dbquery(
      type: "postgresql"
      schema: "public"
      table: "todos"
      dml: INSERT
      configuration: "postgresql_config"
    )

変更後

api/postgresql/index.graphql
  insertTodos(title: String!, completed: Boolean): Todos
    @dbquery(
      type: "postgresql"
      schema: "public"
      query: """
      INSERT INTO todos (title, completed) VALUES (
        $1,
        COALESCE($2, false)
      )
      RETURNING *
      """
      configuration: "postgresql_config"
    )

準備ができたら、apiフォルダにて下記コマンドでデプロイします。

api
stepzen start

上記コマンドは、スキーマの修正保存ごとにデプロイが実行されます。必要に応じて、Ctrl-Cで停止してください。
StepZen Dashboardの Explorer 画面でDocsBuilderで指定したスキーマの API が作られていることが確認できます。

image.png

これで、GraphQL APIの構築が完了しました。次からは、クライアント側のNext.js/Reactのコードを修正することで、GraphQL APIの呼び出しを実装していきます。

5. GraphQL Code Generator を使った クライアントコード自動生成

必要なデータを取得するクエリを準備して、GraphQL Code Generator を使ってクライアントコードを生成します。

以下では、2章で作成したmy-appフォルダに移動して、作業していきます。

working_directory
cd my-app

まず、StepZen Dashboard で動作を確認しながらクエリを準備します。
以下のように、CRUD 操作を行うクエリを作成します。ファイルは、2章で作成したmy-appフォルダのcomponentsに置くことにします。

my-app/components/query.graphql
query getTodos {
  todosList {
    id
    title
    completed
    created_at
    updated_at
  }
}

mutation updateTodo($id: ID!, $completed: Boolean, $title: String) {
  updateTodos(id: $id, completed: $completed, title: $title) {
    completed
    created_at
    id
    title
    updated_at
  }
}

mutation insertTodo($title: String!, $completed: Boolean) {
  insertTodos(title: $title, completed: $completed) {
    completed
    created_at
    id
    title
    updated_at
  }
}

mutation deleteTodo($id: ID!) {
  deleteTodos(id: $id) {
    completed
    created_at
    id
    title
    updated_at
  }
}

次に、GraphQL Code Generator に必要なものをインストールします。

my-app
npm install graphql @graphql-codegen/client-preset
npm i -D @graphql-codegen/cli

設定ファイルを準備します。クエリの.graphqlファイルと StepZen 上のスキーマファイルから、./src/gql/フォルダに型情報等のコードを生成します。

my-app/codegen.ts
import { CodegenConfig } from "@graphql-codegen/cli";

import * as dotenv from "dotenv";
dotenv.config();
const apiKey = process.env.NEXT_PUBLIC_API_KEY || "invalid-api-key";
const uri = process.env.NEXT_PUBLIC_API_URI || "invalid-api-uri";

const config: CodegenConfig = {
  schema: [
    {
      [uri]: {
        headers: {
          Authorization: `Apikey ${apiKey}`,
        },
      },
    },
  ],
  documents: ["./**/*.graphql"],
  ignoreNoDocuments: true, // for better experience with the watcher
  generates: {
    "./src/gql/": {
      preset: "client",
    },
  },
};

export default config;

.envファイルに下記のようにスキーマの URL と API Key を記載します。

my-app/.env
NEXT_PUBLIC_API_URI=https://xxx.stepzen.net/api/todos/__graphql
NEXT_PUBLIC_API_KEY=xxx::stepzen.net+1000::yyy

API Key は StepZen の下記画面から取得できます。

image.png

また、URLはStepZenの下記画面から取得できます。
image.png

package.jsonscriptsに自動生成コマンドのショートカットを追加します。

my-app/package.json
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "codegen": "graphql-codegen-esm --config codegen.ts"
  },

下記コマンドで自動生成を実施します。

my-app
npm run codegen

my-app/src/gql/にコードが生成されていることを確認します。

6. Next.js/React からの GraphQL API 呼び出し

Next.js/React のコードから自動生成したクライアントを使用して、StepZen で構築した GraphQL API を呼び出していきます。

まず、必要なパッケージをインストールします。

my-app
npm install @apollo/client

まず、pages/_app.tsxを追加し Apollo Client を使用できるように記載します。
API Key と GraphQL API のスキーマの URI は、congen.tsと同様、.envファイルから取得します。

my-app/pages/_app.tsx
import { ApolloProvider } from "@apollo/client";
import { AppProps } from "next/app";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";

const apiKey = process.env.NEXT_PUBLIC_API_KEY || "invalid-api-key";
const uri = process.env.NEXT_PUBLIC_API_URI || "invalid-api-uri";

const httpLink = new HttpLink({
  uri: uri,
});
const authLink = setContext((_, { headers }) => {
  return {
    headers: {
      ...headers,
      authorization: `Apikey ${apiKey}`,
    },
  };
});

const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: authLink.concat(httpLink),
});

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

export default MyApp;

また、TodoList.tsx を以下のように変更します。@apollo/clientuseQueryuseMutationを用いて GraphQL API にアクセスします。また、その引数として GraphQL Code Generator で生成したオブジェクトを指定しています。
さらに、追加・修正・削除時に、update 関数を呼び出し、クエリで取得した todo 配列のキャッシュを更新しています。少し複雑なコードに見えるかもしれませんが、Apollo の公式ドキュメント(Apollo GraphQL Docs)を参照しながら確認してみてください。

my-app/components/TodoList.tsx
import { List } from "@mui/material";
import React from "react";
import TodoItem from "./TodoItem";
import TodoInput from "./TodoInput";
import { useMutation, useQuery } from "@apollo/client";
import {
  DeleteTodoDocument,
  GetTodosDocument,
  InsertTodoDocument,
  UpdateTodoDocument,
} from "@/src/gql/graphql";

const TodoList: React.FC = () => {
  const { data, loading, error } = useQuery(GetTodosDocument);

  const [insertTodo] = useMutation(InsertTodoDocument, {
    update(cache, { data }) {
      const existingData = cache.readQuery({ query: GetTodosDocument });
      cache.writeQuery({
        query: GetTodosDocument,
        data: {
          todosList: [...existingData?.todosList!, data?.insertTodos!],
        },
      });
    },
  });
  const addTask = (task: string) => {
    insertTodo({ variables: { title: task } });
  };

  const [updateTodo] = useMutation(UpdateTodoDocument, {
    update(cache, { data }) {
      const existingData = cache.readQuery({ query: GetTodosDocument });
      const updatedTodos = existingData?.todosList?.map((todo) =>
        todo?.id === data?.updateTodos?.id ? data?.updateTodos! : todo
      );
      cache.writeQuery({
        query: GetTodosDocument,
        data: { todosList: updatedTodos },
      });
    },
  });
  const toggleTask = (index: string, completed: boolean) => {
    updateTodo({ variables: { id: index, completed: !completed } });
  };

  const [deleteTodo] = useMutation(DeleteTodoDocument, {
    update(cache, { data }) {
      const existingData = cache.readQuery({ query: GetTodosDocument });
      const updatedTodos = existingData?.todosList?.filter(
        (todo) => todo?.id !== data?.deleteTodos?.id
      );
      cache.writeQuery({
        query: GetTodosDocument,
        data: { todosList: updatedTodos },
      });
    },
  });
  const deleteTask = (index: string) => {
    deleteTodo({ variables: { id: index } });
  };

  return (
    <div>
      <TodoInput addTask={addTask} />
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      <List>
        {data?.todosList?.map((task, index) => (
          <TodoItem
            key={index}
            task={task?.title!}
            completed={task?.completed!}
            toggleTask={() => toggleTask(task?.id!, task?.completed!)}
            deleteTask={() => deleteTask(task?.id!)}
          />
        ))}
      </List>
    </div>
  );
};

export default TodoList;

以上で、Next.js/React のコードの修正は完了です。
下記コマンドで実行して動作確認します。

my-app
npm run dev

http://localhost:3000 に接続すると、以下のような画面が表示されると思います。
image.png

GraphQL API を呼び出さない最初のバージョンと動きは同じですが、Neon のTablesからデータを見ると、実際にデータが永続化されていることがわかります。
以上で、今回のハンズオンは完了です。

おわりに

このハンズオンを通じて、Next.js, GraphQL, StepZen などの最先端技術を用いたモダンな Web アプリの開発手法を学ぶことができたと思います。実際の手順に沿ってアプリを構築し、一連の流れを体験することで、今後の技術選定やプロジェクトのスタートに役立てることができるでしょう。ご質問やご指摘があればコメント欄によろしくお願いいたします。

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?