27
28

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 3 years have passed since last update.

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

Last updated at Posted at 2021-06-13

作ったもの

タイトルなし.gif

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

なお店舗情報の取得には、ホットペッパーWebサービスのグルメサーチAPIを利用させて頂きました。

この記事で学べること

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

学べないこと

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

開発環境

  • os: macOS Big Sur version 11.2
  • React.js: version 17.0
  • Next.js: version 10.2
  • tailwind css: version 2.1

開発の流れ

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

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

1. APIキーの取得

APIキーの取得手順

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

  1. リクルートのWEBサービスサイトにアクセスし、新規登録ボタンをクリックします。

 2021-06-11 10.32.42.png

  1. メールアドレスを入力し、送信ボタンをクリックします。
     2021-06-11 10.32.57.png

  2. リクルートWebサービスからメールが届くので内容を確認し、承認する為のURLをクリックします。
     2021-06-11 10.29.58.png

  3. 承認の確認がとれると、登録したメールアドレス宛にAPIキーが送信されます。
     2021-06-11 10.30.07.png

  4. 最後にメールを受信し、リクルート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

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

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

結果が変更されました。

グルメサーチAPIは、他にも様々なパラメータを指定できます。
詳細については、公式リファレンスにて確認して下さい。

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

Next.jsのセットアップ

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

$ npx create-next-app 

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

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

環境構築

API KEYの設定

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

$ touch .env
.env
API_KEY={あなたのAPI_KEYを入力}

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

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

tailwind cssのインストール

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

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

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

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

tailwind cssには、公式の英語版の他に日本語版のドキュメントも用意されています。参照下さい。

node-fetchのインストール

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

$ npm install -D node-fetch

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

3. 実装

実装の流れ

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

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

APIデータの取得

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

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

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

 2021-06-11 12.39.33.png

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

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

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

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コンポーネントに渡し、ブラウザのコンソール画面に表示しています。

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

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

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

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

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

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

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

スタイルを整える

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

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のドキュメントのレスポンスフィールドを確認すると検索結果の開始位置を示すresults_startと検索結果の件数results_returnedを使うと、もっと読む機能を実装できそうです。

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

CORSについては、こちらの記事を参照下さい。

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>
    </>
  )
}


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から返ってきたレスポンスを店舗データ配列に格納します。
  • 画面が再描画され、追加分の店舗データが表示されます。

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

検索機能の追加

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

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>

〜〜〜

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

4. Vercelへデプロイ

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

Vercelのデプロイ手順は、しまぶーさんのNext.jsプロジェクトをVercelにデプロイする方法が動画でとてもわかりやすく説明されています。

Vercelへのデプロイについて更に詳しく知りたい方は、公式ドキュメントを参照下さい。

おわりに

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

参考サイト

27
28
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
27
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?