RailsAPIとNext.jsでページネーション付きの記事一覧とスラッグURLの記事ページを作成する

みんな大好きページネーション

Next.js

こんにちは。インターネット。

タイトルのとおりですが、Ruby on Rails APIから出力した投稿情報JSONを、Next.jsでフェッチして、ページネーション付きの記事一覧を作成します。

RailsAPI設定

今回悩んだのはAPI設計。考えた結果、個別の記事データはスラッグで記事を取得するようにしました。
API設計としてどうなんでしょうね。(APIへのリクエストは記事IDベースの方が良さそうではある)

JSONの仕様としてはこんな感じです

  • /v1/allposts で全ての投稿のスラッグを返す(GET)
  • /v1/posts でページネーション1ページ目の投稿データとページネーション情報を返す(GET)
  • postsの有効パラメータはpagedとper。ページネーション指定と1ページあたり表示件数です。
  • /v1/posts/hogehoge でスラッグがhogehogeの記事情報を返す(GET)
  • JSONはjbuilderで出力する

RailsAPI側のページネーション機能はGemのKaminariを使用します。さくさくっとインストールします。

/Gemfile

gem 'kaminari'
$ bundle install

次に、モデルですが、今回はPostを用意しました。scaffoldしておいてください。

$ rails g scaffold Post title:string body:text slug:string

こちらがページネーションモジュールです。concernにpagination.rbを作成します。

/app/controllers/concerns/pagination.rb

module Pagination
  extend ActiveSupport::Concern

  def pagination(records)
    {
      total_count: records.total_count,
      limit_value: records.limit_value,
      total_pages: records.total_pages,
      current_page: records.current_page
    }
  end
end

続いてコントローラーをいじっていきます。
さきほどのPaginationをインクルードします。
/app/controllers/v1/posts_controller.rb はこんな感じ。(update, destroyは記載していません)

class V1::PostsController < ApplicationController
  include Pagination
  before_action :set_post, only: %i[ show ]
  
  def index
    #すべての投稿
    @posts = Post.order(created_at: :desc)
    #ページネーション指定ページ
    paged = params[:paged]
    #指定がない場合はデフォルトを10ページずつ(kaminari標準のlimit_valueは25)
    per = params[:per].present? ? params[:per] : 10
    #ページネーションが適用された投稿
    @posts_paginated = @posts.page(paged).per(per)
    #ページネーション情報(concernに記載)
    @pagination = pagination(@posts_paginated)
    ##jbuilderで出力するのでrenderはいらない
  end

  def allposts
    #すべての投稿
    @allposts = Post.order(created_at: :desc)
    ##jbuilderで出力するのでrenderはいらない
  end

  def show
    render json: @post
  end

  private
    def set_post
      @post = Post.find_by(slug: params[:slug])
    end
end

viewにallposts.json.jbuilder を作成

json.posts @allposts, :slug

同じくviewに index.json.jbuilder を修正

json.posts @posts_paginated, :id, :title, :body, :slug, :created_at
json.pagination @pagination

configのroute.rb を修正

Rails.application.routes.draw do
  namespace :v1, { format: 'json' } do
    resources :posts, param: :slug
    get 'allposts/', to: 'posts#allposts'
  end
end

resources :posts, param: :slugとすることで、標準の[:id]から[:slug]に変更できます。
それとallpostsもgetで追加しておきます。

データを流し込み

ここまできたらいい感じにデータを流し込みましょう。

アソシエーションデータ流し込みの話はこちら

Railsでアソシエーションのある複数のデータをseedで流し込む|KYONOHALKYO

APIの確認

PostmanでもブラウザでもいいのでAPIを叩いて確認してみましょう。

localhost/v1/posts/

{
  "posts": [
    {
      "id": 158,
      "title": "たいとる",
      "body": "本文本文本文本文本文本文本文本文",
      "slug": "fuga",
      "created_at": "2022-01-27T15:57:18.624+09:00"
    },//中略
    {
      "id": 149,
      "title": "ここにタイトルが入ります",
      "body": "本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文",
      "slug": "wqrZgWolw_h_-Uclv_Teqw",
      "created_at": "2022-01-27T11:02:56.397+09:00"
    }
  ],
  "pagination": {
    "total_count": 151,
    "limit_value": 10,
    "total_pages": 16,
    "current_page": 1
  }
}

postsのなかに投稿が配列で入っています。paginationの中にはページネーション情報が入っています。
上記リクエストだとパラメーターがないので初期値の”limit_value”:10と”current_page”:1になっています。

このアクションではJSONの出力にjbuilderを用いているため、postオブジェクトの必要な項目のみ表示されています。

次に、パラメーター付きのリクエストです。

localhost/v1/posts?paged=2&per=4

{
  "posts": [
    {
      "id": 154,
      "title": "ここにタイトルが入ります",
      "body": "本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文",
      "slug": "ot1A59ezhsG-A_nq1orzMw",
      "user_id": 2,
      "created_at": "2022-01-27T11:02:56.392+09:00"
    },//中略
    {
      "id": 151,
      "title": "ここにタイトルが入ります",
      "body": "本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文本文",
      "slug": "V1dC6ZEtTTGvQWOEM-uBNg",
      "user_id": 2,
      "created_at": "2022-01-27T11:02:56.387+09:00"
    }
  ],
  "pagination": {
    "total_count": 151,
    "limit_value": 4,
    "total_pages": 16,
    "current_page": 2
  }
}

サンプルデータが適当すぎて申し訳ないです。とりあえず、パラメーターによってレスポンスが変わることが確認できました。

そして、全記事のslugを返すエンドポイントallpostsです。

localhost/v1/allposts

{
  "posts": [
    {
      "slug": "fuga"
    },//だいぶ中略
    {
      "slug": "vgKSCCKa-ILQkl182RAnuA"
    }
  ]
}

たくさんのslugが返ってきますね。これもjbuilder出力で項目を絞っています。
このエンドポイントで取得するスラッグのリストは、Next.jsのSSG(静的サイト生成)の際に必要になります。
通常の/v1/postsでは、kaminariによって取得件数が絞られるうえ、そもそもタイトルやIDは不要なので専用のエンドポイントに切り出しています。SSGの場合はAPIへのリクエストは少ないので、神経質に考える必要はないかもしれませんが、一応APIへの負荷低減的とビルド時間の短縮的な意味合いで。
アクションで切り分けていれば後々、記事一覧取得だけ認証を取るとかやりやすいですし。

localhost/v1/posts/fuga

個別ページです。これはcontrollerからpostオブジェクトをJSON形式で出力しています。

{
  "id": 158,
  "title": "たいとる",
  "body": "本文本文本文本文本文本文本文本文",
  "created_at": "2022-01-27T15:57:18.624+09:00",
  "updated_at": "2022-01-27T15:57:18.624+09:00",
  "slug": "fuga"
}

なお、今回はidでなくslugでアクセスするので、データベース側にUNIQUE制約をつけたり、モデルにバリデーションを付けておきましょう。


Next.jsをやっていく

前置きが長くなりましたが、Next.js側の実装です。

記事一覧はSSR(サーバサイドレンダリング)で、記事ページをSSG(静的サイト生成)にします
SSR/SSG/CSRの違いの解説はこちらがわかりやすいです

SSR、SSG、Client Side Renderingの違いをまとめた – Qiita

さて、そういうことでやっていきます。
使用はこんな感じです

  • /posts で記事一覧ページ
  • /posts?paged=1 のようにページネーションの値はパラメータでやり取りする。(表示件数はアプリ内で指定する)
  • /posts/hoge で個別ページを表示(hogeは記事のスラッグ)
  • 今回はCSSはやらない

とりあえずソースコードがこちら

app/.env.development.local

API_URL = http://localhost/v1/

環境変数の設定はこちら

Next.jsで環境変数を扱う|KYONOHALKYO

app/components/pagination.js

import Link from 'next/link'

const Pagination = (props) => {
    // 取得した総post数
    // const total_count = props.pagination.total_count
    // 1ページあたりの表示件数
    // const limit_value = props.pagination.limit_value
    // 総ページ数
    const total_pages = props.pagination.total_pages
    // 現在のページ
    const current_page = props.pagination.current_page
    // ページネーションの表示リンク数上限(3以上)
    const max_size = 10
    // 実際のページネーションの表示リンク数
    const pagination_size = (total_pages > max_size) ? max_size : total_pages
    // ページネーションの真ん中の基準ライン
    const center_border =  Math.floor(max_size/2)
    // センター時のスタートのポイント
    const centerd_start_point = current_page - center_border
    // ページネーションの右側の基準ライン
    const right_border = total_pages - center_border + 1
    // 右側時のスタートのポイント
    const righted_start_point = total_pages - pagination_size +1

    // リンクの配列
    const links = () => {
        if(current_page <= center_border){
            //current_pageがcenter_borderと同じもしくは左にいるとき
            return(
                new Array(pagination_size).fill(1).map((n, i) => n + i)
            )
        }else if(center_border < current_page && current_page < right_border){
            //current_pageがcenter_borderより右、right_borderより左にいるとき
            return(
                new Array(pagination_size).fill(centerd_start_point).map((n, i) => n + i)
            )
        }else if(right_border <= current_page){
            // current_pageがright_borderと同じもしくは右にいるとき
            return(
                new Array(pagination_size).fill(righted_start_point).map((n, i) => n + i)
            )
        }
    }
    // 文字列(currentはリンクなし)
    const PageLink = (i) =>{
        if(i==current_page){
            return(
                <>
                    <span> {i} </span>
                </>
            )
        }else{
            return(
                <>
                    <Link href={"posts?paged="+i}><a> {i} </a></Link>
                </>
            )
        }
    }
 
    return (
        <div>
            {links().map(
                (link) =>
                    <span key={link}>
                        {PageLink(link)}
                    </span>
                )
            }
        </div>
    )
}
export default Pagination

max_sizeはページネーションのリンクの最大表示数です。(ページ数が少ない場合は実数が優先されます)

ページネーションコンポーネントの書き方がスマートじゃない気がするので、誰か・・・

app/pages/posts/index.js

import Link from 'next/link'
import Pagination from '../../components/pagination.js'

export async function getServerSideProps(context){
  // 指定がない時は1ページ目から
  const paged = context.query.paged ?? 1
  // 表示数の指定(APIデフォルトは10)
  const per = 10
  const response = await fetch(process.env.API_URL+"posts/?paged="+paged+"&per="+per, {'method': "GET"});
  const data = await response.json();
  const posts = data.posts
  const pagination = data.pagination
  return {
    props: {
      posts: posts,
      pagination: pagination,
      paged: paged,
      per: per
    },
  };
}

const Posts = (props,res) => {
  return (
    <div>
      <h1>Posts</h1>
      <Pagination pagination={props.pagination}/>
        <ul>
          {props.posts.map((post) =>
            <li key={post.id}>
              <h2>
                <Link href={"./posts/"+post.slug}>
                  <a>
                    {post.title}
                  </a>
                </Link>
              </h2>
                  <p>{post.created_at}</p>
            </li>
          )}
        </ul>
        <Pagination pagination={props.pagination}/>
        <Link href="../">
        <a>Back</a>
        </Link>
    </div>
  )
}
export default Posts;

ここのperは1ページあたりの記事表示数です。

app/pages/posts/[slug].js

import Link from 'next/link'

const Post = ({ post }) => {
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <Link href="./">
        <a>Back</a>
      </Link>
    </div>
  )
}
export default Post;


export const getStaticPaths = async () => {
  const res = await fetch(process.env.API_URL+"allposts/", {method: "GET"})
  const data = await res.json()
  const allposts = data.posts
  const paths = allposts.map((post) => ({
    params: {
      slug: post.slug
    },
  }))
  return { paths, fallback: false }
}

export const getStaticProps = async ({ params }) => {
  const slug = params.slug
  const res = await fetch(process.env.API_URL+"posts/"+slug, {method: "GET"})
  const post = await res.json()
  return {
    props: {
      post
    },
  }
}

表示してみる

/posts/にアクセスするとページネーション付きの記事一覧が表示されます。

ページネーションはcurrent_page(今いるページ)はリンク無しで表示されます。ページを進んでいくと中央にとどまります。

記事ページの確認

/posts/fugaにアクセスしてみます

ちゃんと表示されてます。やったー

おわり🏆

公開日