こんにちは。インターネット。
タイトルのとおりですが、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/
環境変数の設定はこちら
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にアクセスしてみます
ちゃんと表示されてます。やったー
おわり🏆