【Next.js】Next.js+tailwind cssでシンプルなグルメ店検索アプリを作ってみた!

### 作ったもの

![タイトルなし.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/1fa8cd59-977d-aa06-d5cf-6453b421d28a.gif)

作ったアプリのタイトルは、東京グルメ店検索です。

- [実際のサービス](https://vercel-hot-pepper-gourmet.vercel.app/)
- [ソース](https://github.com/dtakkiy/search_hot_pepper)

なお店舗情報の取得には、[ホットペッパーWebサービス](https://webservice.recruit.co.jp/)のグルメサーチAPIを利用させて頂きました。

### この記事で学べること

- Next.jsによるシンプルなアプリの構築手順
- tailwind cssの基本的な使い方
- 外部APIの叩き方
- 検索機能の実装
- もっと読む機能の実装

### 学べないこと

- Type Script
- Next.jsの高度な使い方

### 開発環境

- os: macOS Big Sur version 11.2
- React.js: version 17.0
- [Next.js](https://nextjs.org/): version 10.2
- [tailwind css](https://tailwindcss.com/): version 2.1

## 開発の流れ

この記事では、以下のような流れで開発を進めます。

1. APIキーの取得
1. Next.jsのセットアップ及び環境構築
1. 実装
1. Vercelへデプロイ

## 1. APIキーの取得

### APIキーの取得手順

このアプリでは、HOT PEPPERグルメのAPIを利用するため、事前にユーザ登録が必要になります。
以下の手順に従って、登録を行って下さい。

1. [リクルートのWEBサービスサイト](https://webservice.recruit.co.jp/)にアクセスし、新規登録ボタンをクリックします。

![ 2021-06-11 10.32.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/63fbd703-bde1-c64d-6984-90ecce8d0f50.png)

2. メールアドレスを入力し、送信ボタンをクリックします。
![ 2021-06-11 10.32.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/1e5ee737-5c17-1c43-6e0e-d7e63c16d9cb.png)

3. リクルートWebサービスからメールが届くので内容を確認し、承認する為のURLをクリックします。
![ 2021-06-11 10.29.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/6a595e09-8719-5854-9c99-f75757d73fd5.png)

4. 承認の確認がとれると、登録したメールアドレス宛にAPIキーが送信されます。
![ 2021-06-11 10.30.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/156d3fa8-95f2-0053-3aa6-40d4301a5f3e.png)

5. 最後にメールを受信し、リクルートWebサービスから発行されたAPIキーを確認して下さい。

### APIのテスト

今回は、リクルートWebサービスのAPIの一つ、グルメサーチAPIを利用します。

リファレンスによるとグルメサーチAPIのエンドポイントは以下となります。

`http://webservice.recruit.co.jp/hotpepper/gourmet/v1/`


ブラウザに下記のURLを入力しページを開いてみて下さい。
`https://webservice.recruit.co.jp/hotpepper/gourmet/v1/?key={取得したあなたのAPIキー}&keyword={好きなキーワード}`

このようなデータが表示されていれば、データの取得は成功です。
![ 2021-06-11 11.12.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/4f4f39e9-d5c9-9a2e-9db3-ecce83673adb.png)

またグルメサーチAPIは、各種パラメータを指定することで検索対象を指定することができます。
例えばクエリにlarge_areaに`Z011`を含めることで、検索対象を東京に指定することができます。
下記URLを入力し、再度ブラウザで実行してみて下さい。

`https://webservice.recruit.co.jp/hotpepper/gourmet/v1/?key={取得したあなたのAPIキー}&large_area=Z011&keyword={好きなキーワード}`

結果が変更されました。

グルメサーチAPIは、他にも様々なパラメータを指定できます。
詳細については、[公式リファレンス](https://webservice.recruit.co.jp/doc/hotpepper/reference.html)にて確認して下さい。

## 2. Next.jsのセットアップ及び環境構築

### Next.jsのセットアップ

ターミナルを立ち上げ、下記のコマンドを実行しNext.jsのプロジェクトを作成します。
プロジェクト名は、お好きなものを指定して下さい。

```bash
$ npx create-next-app 
```

プロジェクトが作成されたら下記のコマンドで開発者サーバを起動します。
ブラウザで`http://localhost:3000`を開き、作成したプロジェクトにアクセスできるか確認して下さい。

```bash
$ cd 作成したプロジェクト名
$ npm run dev
```

### 環境構築

#### API KEYの設定

先程の取得したAPI KEYの設定をプロジェクトに追加します。
.envファイルを作成し、API KEYの値を追加します。

```bash
$ touch .env
```

```txt:.env
API_KEY={あなたのAPI_KEYを入力｝
```

設定したAPI_KEYをコード上で利用する際は、`process.env.API_KEY`と記述します。

以上でAPI KEYの設定は終了です。

#### tailwind cssのインストール

このアプリでは、cssスタイリングにtailwind cssを利用します。
下記のコマンドを実行し、next.jsにtailwind cssを追加してください。

```bash 
$ npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
$ npx tailwindcss init -p
```

また`styles/globals.css`ファイルに下記の記述を追加します。

```css:styles/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

tailwind cssには、[公式の英語版](https://tailwindcss.com/docs)の他に[日本語版のドキュメント](https://tailwindcss.jp/)も用意されています。参照下さい。

#### node-fetchのインストール

このアプリでは、Next.jsのAPIルートでfetch関数を使用するため、`node-fetch`を追加インストールします。

```bash
$ npm install -D node-fetch
```

以上で開発環境の構築は完了です。

### 3. 実装

#### 実装の流れ

実装は、以下のような流れで進めます。

a. APIデータの取得
b. 取得データの一覧表示
c. スタイリング
d. もっと読む機能の追加
e. 検索機能の追加

#### APIデータの取得

HOT PEPPER APIのレスポンスは、デフォルトではxml形式で返ってきます。
（私は当初、レスポンスがxml形式で返ってくることに気づかず、データが表示できないため？？？になっていました。）
javascriptではそのままの形式では扱いづらいので、クエリにfomat=jsonを追加しjson形式を指定します。

```javascript:pages/index.js
const defaultEndpoint =
  'https://webservice.recruit.co.jp/hotpepper/gourmet/v1/?format=json'
```

またHOT PEPPER APIは、ApiKeyの他に１つ以上の条件を指定しないと下記の様なエラーを返す仕様になっています。
（こういったエラーも初心者には気づきにくいので、初見のAPIを確認するときはコード上ではなく、まずブラウザを使ってレスポンスを確認することをオススメします。）

![ 2021-06-11 12.39.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/23d28f75-8984-5566-8041-0d601e9e9e6f.png)

このままだとエラーが返ってきてしまうため、データが取得できません。
そのためデフォルトの検索条件にエリアを東京`large_area=Z011`と追加することにしました。

```javascript:pages/index.js
const defaultEndpoint = `https://webservice.recruit.co.jp/hotpepper/gourmet/v1/?format=json&large_area=Z011`
```

初期のエンドポイントは、ここにAPI KEYの指定を追加しています。
これでデータ取得が可能な形になりました。

```javascript:pages/index.js
const defaultEndpoint = `https://webservice.recruit.co.jp/hotpepper/gourmet/v1/?key=${process.env.API_KEY}&format=json&large_area=Z011`
```

初期のエンドポイントが決まったので、
次にコード上から取得したデータをブラウザのコンソール画面に値を表示し確認してみます。

以下のコードでは、サーバサイドで実行した取得結果`data`をpropsをつかってHomeコンポーネントに渡し、ブラウザのコンソール画面に表示しています。

```javascript:pages/index.js
const defaultEndpoint = `https://webservice.recruit.co.jp/hotpepper/gourmet/v1/?key=${process.env.API_KEY}&format=json&large_area=Z011`

export async function getServerSideProps() {
  const res = await fetch(defaultEndpoint)
  const data = await res.json()

  return {
    props: {
      data,
    },
  }
}

export default function Home({ data }) {
  // コンソール画面に表示
  console.log(data.results)

}
```

![ 2021-06-13 8.01.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/b1c0326d-0214-8e9e-b523-74b3e0b93fe0.png)

うまく表示されたでしょうか？
コンソール画面表示で`data`ではなく`data.results`を指定しているのは、APIのレスポンスフィールドのトップフィールドに`results`が指定されていためです。詳しくは、公式リファレンスを参照下さい。

### 取得したデータの一覧表示

次に取得した店舗データをmap関数を使って、画面に一覧表示します。
まず、pages/index.jsのHome関数の中身を削除し、画面表示をまっさらな状態にします。

```javascript:pages/index.js
export default function Home({ data }) {
    return (
    <>
    <>
    )
}
```

次に、ここに一覧表示のコードを記述します。
リストに識別性を与えるため、それぞれの項目にkeyを設定します。
ここではkeyにindex番号を付与しました。
（当初、私はリストのkeyに店舗ID`shop.id`を割り当てていましたが、実際に実行してみると検索結果のレスポンスには同一店舗が複数含まれており、IDが重複していました。そのため、コンソール画面はwarnningで溢れました。最終的にリストのkeyをindex番号に変更し対処しました。）

```javascript:pages/index.js
# 先頭行に追記
import Head from 'next/head'
import Link from 'next/link'

〜〜〜

export default function Home({ data }) {

  return (
    <>
        <Head>
          <title>東京グルメ店検索</title>
        </Head>
        <ul>
          {data.results.shop.map((item, index) => {
            return (
              <li key={index}>
                <Link href={item.urls.pc}>
                  <a>
                    <div >
                      <div >
                        <div>
                          <img src={item.photo.mobile.s} alt={item.name} />
                        </div>
                      </div>
                      <div>
                        <div> {item.name}</div>
                        <div>
                          <div>
                            <span>
                              {item.genre.name}
                            </span>
                            <span>{item.catch}</span>
                          </div>
                          <p> {item.access}</p>
                        </div>
                      </div>
                    </div>
                  </a>
                </Link>
              </li>
            )
          })}
        </ul>
   </>
  )
}
```

まだスタイルがあたっていないので見づらいかもしれませんが、これで取得したデータが画面に表示されました。
またLinkを追加しているので、店舗の情報をクリックすると、各店舗サイトに画面が遷移するはずです。

![ 2021-06-11 13.00.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/55397297-285b-09d1-bc8e-c47a70e1c2cd.png)


### スタイルを整える

続いて、tailwind cssを使って、画面表示を整えていきます。
`pages/index.js`にインラインでスタイルを書き加えていきます。

```javascript:pages/index.js
export default function Home({ data }) {

  return (
    <>
      <Head>
        <title>東京グルメ店検索</title>
      </Head>
      <div className="max-w-3xl font-mono bg-gray-100 mx-auto">
        <ul className="mx-4">
          {data.results.shop.map((item, index) => {
            return (
              <li
                key={index}
                className="my-4 bg-white rounded border-red-500 border-2"
              >
                <Link href={item.urls.pc}>
                  <a>
                    <div className="grid grid-cols-10">
                      <div className="col-span-2 self-center">
                        <div>
                          <img src={item.photo.mobile.s} alt={item.name} />
                        </div>
                      </div>
                      <div className="ml-3 col-span-8">
                        <div className="text-lg mt-2 mr-2"> {item.name}</div>
                        <div className="text-xs mt-2 mr-2 pb-2">
                          <div className="text-xs">
                            <span className="font-medium">
                              {item.genre.name}
                            </span>
                            <span className="ml-4">{item.catch}</span>
                          </div>
                          <p className="mt-1"> {item.access}</p>
                        </div>
                      </div>
                    </div>
                  </a>
                </Link>
              </li>
            )
          })}
        </ul>
      </div>
    </>
  )
}
```

スタイルがあたることで、徐々にそれらしい画面になってきたのではないでしょうか。

### もっと読む機能の追加

このままだと店舗データが規定値の10件までしか表示できないため、画面の下部にもっと読むボタンを追加し、追加データの表示ができるようにしたいと思います。

[公式APIのドキュメントのレスポンスフィールド](https://webservice.recruit.co.jp/doc/hotpepper/reference.html)を確認すると検索結果の開始位置を示す`results_start`と検索結果の件数`results_returned`を使うと、もっと読む機能を実装できそうです。

ただ、このままクライアントサイドから外部APIであるリクルートWEBサービスにリクエストを送るとCORSエラーとなるため、今回はこのエラーを回避するためNext.jsのAPIルートを経由することにしました。

CORSについては、[こちらの記事](https://qiita.com/att55/items/2154a8aad8bf1409db2b)を参照下さい。

```javascript:pages/index.js
// 冒頭に追記
import { useState, useEffect } from 'react'

export default function Home({ data }) {
  const {
    results_available = 0,
    results_start = 1,
    shop: defaultShops = [],
  } = data.results

  //取得した店舗データを格納
  const [shop, updateShops] = useState(defaultShops)

  //取得したページデータを格納
  const [page, updatePage] = useState({
    results_available: results_available,
    results_start: results_start,
  })

  // 開始位置の変更を監視
  useEffect(() => {
    if (page.results_start === 1) return

    const params = { start: page.results_start, keyword: keyword }
    const query = new URLSearchParams(params)

    const request = async () => {
      const res = await fetch(`/api/search?${query}`)
      const data = await res.json()
      const nextData = data.results

      updatePage({
        results_available: nextData.results_available,
        results_start: nextData.results_start,
      })

      if (nextData.results_start === 1) {
        updateShops(nextData.shop)
        return
      }

      updateShops((prev) => {
        return [...prev, ...nextData.shop]
      })
    }

    request()
  }, [page.results_start])


  // もっと読むボタンを押したときの処理
  const handlerOnClickReadMore = () => {
    if (page.results_returned <= page.results_start) return

    updatePage((prev) => {
      return {
        ...prev,
        results_start: prev.results_start + 1,
      }
    })
  }

  return (
    <>


〜〜〜

        // もっと読むボタンを画面下部に追加
        </ul>
        {page.results_returned <= page.results_start ? (
          <div></div>
        ) : (
          <div className="text-center pt-4 pb-8">
            <button
              className="bg-red-500 rounded text-white tracking-wider font-medium hover:opacity-75 py-2 px-6 "
              onClick={handlerOnClickReadMore}
            >
              もっと読む
            </button>
          </div>
        )}
      </div>
    </>
  )
}


```

```javascript:pages/api/search.js

import fetch from 'node-fetch'

const defaultEndpoint = `https://webservice.recruit.co.jp/hotpepper/gourmet/v1/?key=${process.env.API_KEY}&format=json&large_area=Z011`

export default async (req, res) => {
  let url = defaultEndpoint

  if (typeof req.query.start !== undefined) {
    url = `${url}&start=${req.query.start}`
  }

  url = encodeURI(url)

  const result = await fetch(url)
  res.json(result.body)
}
```

処理の流れは、以下の通りです。

- もっと読むボタンを押下すると関数`handlerOnClickReadMore`が呼び出されます。
- 関数`handlerOnClickReadMore`では、現在の読み込み位置を示す`results_start`に1を加えます。
- useEffectが`results_start`の変更を検知すると、追加のデータ取得処理が行われます。
- 追加データの取得先は、APIルート /api/search.jsとなります。
- クエリパラメータには、先程の`results_start`+1の値を加えます。
- /api/search.jsは、与えられたパラメータに応じたレスポンスを返します。
- 再度useEffect内の処理に戻り、search.jsから返ってきたレスポンスを店舗データ配列に格納します。
- 画面が再描画され、追加分の店舗データが表示されます。

これで、もっと読むボタンを押す度に店舗データが追加表示されるようになりました。

### 検索機能の追加

次に画面上部に検索フォームを追加し、検索機能を実装します。
検索機能の処理は、渡すクエリパラメータが`start`→`keyword`に変わっただけで、処理の流れは変わりません。

```javascript:pages/index.js

  // キーワードを格納
  const [keyword, setKeyword] = useState('')

  // キーワードの変更を監視
  useEffect(() => {
    if (keyword === '') return

    const params = { keyword: keyword }
    const query = new URLSearchParams(params)
    
    // リクエスト、レスポンスの取得
    const request = async () => {
      const res = await fetch(`/api/search?${query}`)
      const data = await res.json()
      const nextData = data.results

      updatePage({
        results_available: nextData.results_available,
        results_start: nextData.results_start,
      })

      updateShops(nextData.shop)
    }

    request()
  }, [keyword])

  // 検索ボタン押下時の処理
  const handlerOnSubmitSearch = (e) => {
    e.preventDefault()

    const { currentTarget = {} } = e
    const fields = Array.from(currentTarget?.elements)
    const fieldQuery = fields.find((field) => field.name === 'query')

    // keywordをセット
    const value = fieldQuery.value || ''
    setKeyword(value)
  }

  return (
    <>
      <Head>
        <title>東京グルメ店検索</title>
      </Head>
      <div className="max-w-3xl font-mono bg-gray-100 mx-auto">
        <div>
          <div className="text-2xl py-6 text-center">
            <h2 className="font-medium tracking-wider ">東京グルメ店検索</h2>
          </div>
          <div className="">
            <form onSubmit={handlerOnSubmitSearch} className="text-center">
              <input
                type="search"
                name="query"
                className="rounded py-2 px-4 text-left border-red-500"
                placeholder="キーワードを入力して下さい"
              />
              <button className="ml-2 text-white bg-red-500 rounded py-2 px-6 hover:opacity-75">
                Search
              </button>
            </form>
            <div className="text-sm pt-2 text-gray-600 text-center">
              <span>{page.results_available}</span> <span>件</span>
            </div>
          </div>
        </div>

〜〜〜

```

```javascript:pages/api/search.js

export default async (req, res) => {
  let url = defaultEndpoint

  if (typeof req.query.keyword !== undefined) {
    url = `${url}&keyword=${req.query.keyword}`
  }

```

実装すると検索機能が有効になります。

![ 2021-06-13 9.34.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/166484/f06be894-67bd-ee57-e140-c69164042c01.png)


## 4. Vercelへデプロイ

Vercelは、ホスティングサービスを一つです。
Next.jsで作成したアプリは、Vercelを使うとはじめてでもとても簡単にデプロイできます。

Vercelのデプロイ手順は、しまぶーさんの[Next.jsプロジェクトをVercelにデプロイする方法](https://www.youtube.com/watch?v=hL6bDTNQhAc)が動画でとてもわかりやすく説明されています。

Vercelへのデプロイについて更に詳しく知りたい方は、[公式ドキュメント](https://nextjs.org/learn/basics/deploying-nextjs-app)を参照下さい。


## おわりに

いかがだったでしょうか？
この記事が、これからNext.jsを使ってなにかアプリを作ってみようと思っている方のお役に立てれば幸いです。


## 参考サイト

- [Next.js](https://nextjs.org/)
- [tailwindcss Doc](https://tailwindcss.com/docs)
- [HOT PEPPER グルメ API リファレンス](https://webservice.recruit.co.jp/doc/hotpepper/reference.html)
- [Data Fetching in Next.js - Rick and Morty REST API Tutorial](https://www.youtube.com/watch?v=iW39Merz0zE)