概要
React+Next.jsでブログサイトを作成します。この記事は自分のアウトプット目的に書いたものなのでところどころ説明が言葉足らずな部分があるかもしれません。ご了承ください。今回のブログサイトは以下の書籍を参考に作成したので詳細な説明を知りたい場合はそちらをご覧ください。
書籍:はじめてつくるNext.jsサイト(v13) 三好アキ
そのため、アプリ作成の事前準備の方法に関しては省きます。
開発環境
Mac OS Sonoma
Next.js 14.1.4
アプリの構成
まず、完成系は以下のようになります。
ブログサイト
大枠の作成
next.jsとreactの大きな違いがページの遷移をreact-router-domを使用せずにできることです。具体的にはAppフォルダ下にページごとのフォルダを作成し、page.jsファイルを作成します。こうするとurlの末尾に/blogなどの文字を入力するだけでページ遷移が可能になります。またボタンを押すことでリンク先に飛べるような仕組みも簡単に作れます。またComponentsは共通パーツを収めるフォルダでStylesはcssファイルを収めるフォルダです。以下がフォルダ構成となります。
App
-layout.js
-page.js
-Blog
-page.js
-Contact
-page.js
-Components
-Styles
開発手順
フォルダ内のそれぞれpage.jsファイルに雛形を作成してあげます。以下のように作成できます。
const Blog = () => {
return (
<h1>ブログページ</h1>
);
}
export default Blog;
const BlogのBlogの部分はそれぞれ任意の名前で大丈夫です。またApp/page.jsにはリンクを作成したいので、以下のようにを使用してページ移動ができるようにしましょう。
import Link from "next/link";
const Index = () => {
return (
<div>
<h1>こんにちは</h1>
<Link href="/contact">Contactページへ移動</Link>
</div>
);
}
export default Index;
これで必要なページを作成することができました。次は今回の開発のメインであるブログページの作成に移りましょう。
ブログページの作成
今回のブログページ作成にあたって必要な仕組みは以下の3つです。
- ブログページ
- 記事データ
- ブログページに記事データを読み込み、表示する仕組み
ブログページは先ほど作成したblog/page.jsになります。ここに記事データを表示するにはまず記事データが必要です。今回はマークダウン記法を使ってデータを作成しましょう。表示する仕組みについては後々説明します。
それではまずトップ階層にdataというフォルダを作成しましょう。そしてその中にfirst-blog.mdのようにmd拡張子を使用したファイルを作成しましょう。以下のように記述します。
---
id: 1
title: "1つ目の記事"
date: "20xx-05-01"
image:
excerpt:
---
---で挟まれた部分をfrontmatterと呼び基本情報が入力されています。imageとexcerptは後で記述します。それではこれと同じ記事を後5つ作成してください。
その後、一度next.jsをcontrol+cで停止し、以下のコマンドをターミナルに入力してください。
npm install raw loader gray-matter react-markdown
これはマークダウンの処理に必要なモジュールをインポートするためにインストールしました。そしてnext.config.jsファイルに以下のコードを記述します。
const nextConfig = {
webpack: (config, { webpack }) => {
config.module.rules.push({
test: /\.md$/,
use: "raw-loader",
});
return config;
},
};
export default nextConfig;
このコードを記述することによってnext.jsでマークダウン記法を読み込むことができます。このコードではwebpackの設定をカスタマイズしています。mdファイルをraw-loaderで処理するように指示しており、これによってファイルの内容をアプリケーションに文字列として取り込むことができるのです。つまり、先ほど作成したmdファイルをアプリケーションに読み込めるようになったということです。
ここからはマークダウンデータをブログページに読み込む仕組みを作っていきます。以下のコードをblog/page.jsに記述します。
import fs from "fs";
import path from "path";
async function getAllBlogs() {
const files = fs.readdirSync(path.join("data"));
}
const Blog = () => {
return (
<h1>ブログページ</h1>
);
}
export default Blog;
getAllBlogsは記事データを読み込む処理の塊です。そしてその中に書かれているコードが具体的な処理の方法です。
path.join("data")でdataディレクトリのpathを生成し、fs.readdirSync()で指定されたディレクトリの内容を読み取り、その内容をfilesに格納しているというような流れです。
実際に以下のような形でfilesに格納されています。
[
'fifth-blog.md',
'first-blog.md',
'fourth-blog.md',
'second-blog.md',
'sixth-blog.md',
'third-blog.md'
]
今ここまででファイルの読み込みに成功しています。ですがファイルの中にアクセスしていく必要があるので、以下のコードを記述してください。
async function getAllBlogs() {
const files = fs.readdirSync(path.join("data"));
const blogs = files.map((filename) => {
const fileData = fs.readFileSync(
path.join("data", filename),
"utf-8"
)
console.log(fileData);
})
blogs
}
さあかなりややこしくなってきましたね。書籍にはほとんど解説がなく私も理解に苦労しました、、。ですが一つ一つ冷静に理解していきましょう。
まずfiles.map()メソッドは、filesの各要素(first-blog.mdなど)に対してカッコ内に書かれた処理を実行するものです。つまりすべてのfileに対して同じ処理を繰り返してくれます。
そしてカッコ内を見てみると先ほどと似たコードが書かれています。ここでは先ほどと同じくpath.join("data",filename)でdataディレクトリ内のファイルのpathを生成し、fs.readdirSync()で指定されたディレクトリの内容を読み取り、その内容をfileDataに格納しているというような流れです。
つまり、blogsにはそれぞれのファイルのマークダウンデータが格納されているということです。ターミナルを確認すればそれがわかるかと思います。
これで読み込みの仕組みができた!わけではなく、今はわかりやすいようにデータをターミナルに表示する仕組みを作ってあげただけです。ですが実際にはblogsにデータを読み込む必要があるので以下のコードを追加しましょう。
async function getAllBlogs() {
const files = fs.readdirSync(path.join("data"));
const blogs = files.map((filename) => {
const fileData = fs.readFileSync(
path.join("data", filename),
"utf-8"
)
const { data } = matter(fileData);
return {
frontmatter: data,
}
})
return {
blogs: blogs
}
}
さあこれも書籍に解説がなく、理解に苦労しました。一つずつ見ていきましょう。matter(fileData)関数はマークダウンファイルの内容をJavascriptで処理できるように変換してくれます。それを{data}に格納しているのです。具体的には以下のような情報が格納されています。
{
"id": 2,
"title": "2つ目の記事",
"date": "20xx-05-02",
"image": "",
"excerpt": ""
}
このdataがfrontmatterとしてそのまま返されているのです。そして全てのデータをblogs:blogsでblogs配列に格納し、これが返り値として提供されているという形です。
ここまででやっとマークダウンデータを読み込むコードが完成しました。あとはこれを表示する仕組みを作ればブログページの作成が完成します。次のコードを追加しましょう。
const Blog = async() => {
const { blogs } = await getAllBlogs();
return (
<div>
<h1>ブログページ</h1>
{blogs.map((blog, index) =>
<div key={index}>
<h2>{blog.frontmatter.title}</h2>
<p>{blog.frontmatter.date}</p>
</div>
)}
</div>
);
}
もうだいぶ頭が混乱してると思いますが、ここまでが正念場なのでこれだけ理解してしまいましょう。
まず、asyncを関数宣言の前に置くことでawaitが使えるようになります。これによってgetAllBlogsの返り値、つまりblogsの結果が利用可能になった後でそのデータを使うことができるようになるのです。
そしてmapを使用してblogs配列に入ったデータをそれぞれ処理していきます。この部分は簡単でただblogのtile,dateをページに表示するためのコードです。これによって以下の写真のようにブラウザに情報が表示されます。
順番が乱れていますがこれは後で修正するので今はスルーしてください。
これでブログページの作成が完成です!
個別記事ページの作成
ここで一度完成系を再確認しておきましょう。以下のURLをクリックしてください。
ブログサイト
先ほど作成したblogsのページに飛んでもらうと個別に記事があることがわかると思います。いまから作っていくのはこの個別記事です。手順としては以下の通りです。
①個別記事に遷移する仕組みを作る
②中身を作成する
ではまず、個別の記事に遷移する仕組みを作成しましょう。個別の記事を開いてもらえばわかりますが、urlの末尾はmdファイルの名前と一致しています。よってそれをurlように使用できるようにできれば良さそうです。以下のコードを追加してください。
async function getAllBlogs() {
const files = fs.readdirSync(path.join("data"));
const blogs = files.map((filename) => {
const slug = filename.replace(".md", "");
const fileData = fs.readFileSync(
path.join("data", filename),
"utf-8"
)
const { data } = matter(fileData);
return {
frontmatter: data,
slug: slug,
}
})
return {
blogs: blogs
}
}
ここではreplace()を使用してファイル名からmdを削除したものをslugに格納し、それぞれのslugがblogに格納されるようにしました。そしてこれを使ってリンクを作成しましょう。以下のコードを追加してください。
const Blog = async() => {
const { blogs } = await getAllBlogs();
return (
<div>
<h1>ブログページ</h1>
{blogs.map((blog, index) =>
<div key={index}>
<h2>{blog.frontmatter.title}</h2>
<p>{blog.frontmatter.date}</p>
<Link href={`/blog/${blog.slug}`}>Read More</Link>
</div>
)}
</div>
);
}
Linkを作成し、hrefにurlとなるものを追加しています。/blog/の後の${blog.slug}はテンプレートリテラルと呼ばれ、文字列に定数を入れることができます。これにより、first-blog.mdのリンクをクリックすればblog-slugの部分がfirst-blogになり正しいリンクができるのです。それではリンクをクリックしてみましょう。ですがクリックするとエラーが発生していることがわかると思います。理由は主に2つあり、1つは個別の記事ページが作成されていないこと、もう一つはfirst-blogのようなslugがNext.JSサイトの中に登録されていないことです。
一つ目の問題は今までと同じように/app/blog/first-blog/page.jsのようなファイルを作成すれば個別の記事を表示できるようになりそうです。しかしここで問題なのが今は記事数が6個と決まっているので一つずつ作ってもいいのですが、記事数が増加するごとにファイルを作成する必要があることです。よってページを自動で作成できるような仕組みを作れれば解決できそうです。ここからやることは以下の3ステップです。
①マークダウンデータを流し込み、記事データを作成する雛形を作ること
②それぞれのslugを作成する仕組みを作ること
③実際にマークダウンデータを流し込むこと
まずは①から取りかかりましょう。ターミナルをcontrol+Cで停止しておいてください。個別の記事ページは/blog/first-blogのようにblogの下に作る必要があるので[slug]という名前のフォルダをblogの下に作成してください。ここで使われている[]はNext.jsで汎用フォルダ/ファイルを指します。そしてその下にpage.jsを作成します。そして以下のコードを追加してください。
const SingleBlog = () => {
return (
<div>
<h1>記事ページ</h1>
</div>
);
}
export default SingleBlog;
ではもう一度npm run devでサーバーを起動し/blog/abcのようにblogの後の部分に適当な文字を打ってみてください。すると「記事ページ」とだけかかれたページが開かれると思います。これが先ほど述べた汎用ページの機能の一つです。他の文字を入れても同じ内容が表示されると思います。
では手順②に移りましょう。slugの生成と登録を行なっていきます。/[slug]/page.jsの一番下に以下のコードを追加します。
export async function generateStaticParams() {
}
これはslugの生成と登録を行う機能の塊です。まずここに登録したいslugを書いていきます。ここで思い出して欲しいのはmdファイルの名前からslugを作成する仕組みは先ほどすでに作っているため、それをここでもつかってやればいいということです。/blog/page.jsで使用していたコードと同じものを/[slug]/page.jsに追加してやります。
import fs from "fs";
import path from "path";
import matter from "gray-matter";
const SingleBlog = () => {
return (
<div>
<h1>記事ページ</h1>
</div>
);
}
export default SingleBlog;
export async function generateStaticParams() {
async function getAllBlogs() {
const files = fs.readdirSync(path.join("data"));
const blogs = files.map((filename) => {
const slug = filename.replace(".md", "");
const fileData = fs.readFileSync(
path.join("data", filename),
"utf-8"
)
const { data } = matter(fileData);
return {
frontmatter: data,
slug: slug,
}
})
return {
blogs: blogs
}
}
const { blogs } = await getAllBlogs();
}
blogsの中にはslugと記事データが含まれているのでこれによってファイル名からslugを作成できるようになりました。しかし記事データはslugの登録に必要ないので以下のコードでslugのみを格納できるようにします。
const { blogs } = await getAllBlogs();
const paths = blogs.map((blog) => `/${blog.slug}`);
return paths
}
このコードでそれぞれのファイルごとのslugをpathsに格納しています。
それでは手順③の記事データの読み込みを行っていきましょう。ブログページでは全てのマークダウンファイルを読み込みましたが、今回は個別の記事のページなので該当するマークダウンファイルのみ読み込みます。ファイルの名前はslugと同じなので次のコードでslugを確認してみましょう。
const SingleBlog = (props) => {
console.log(props);
return (
<div>
<h1>記事ページ</h1>
</div>
);
}
ターミナルを見てもらうと{ params: { slug: 'abc' }, searchParams: {} }のように表示されていることがわかります。つまり、props.params.sulgにslugが格納されているのです。それでは実際に仕組みを作りましょう。以下のコードを追加してください。
async function getSingleBlog(context) {
}
const SingleBlog = (props) => {
getSingleBlog(props);
return (
<div>
<h1>記事ページ</h1>
</div>
);
}
ここではpropsを新たな機能であるgetSingleBlogに渡してあげました。そして次のようにコードを追加します。
async function getSingleBlog(context) {
const { slug } = context.params;
const data = await import(`../../../data/${slug}.md`);
const singleDocument = matter(data.default);
return {
singleDocument: singleDocument
}
}
const SingleBlog = (props) => {
const { singleDocument } = await getSingleBlog(props);
console.log(singleDocument);
return (
<div>
<h1>記事ページ</h1>
</div>
);
}
ここではまず、{slug}にpropsから渡されたslugを格納しています。そして取得したslugを使ってファイルを指定し、importを使用してdataに格納します。そしてmatter関数でdataのフロントマター部分をsingleDocumentに代入します。そして戻り値にsingleDocumentを設定しています。
そしてSingleBlogではsingleDocumentを使用してデータを表示する仕組みを作ってあげています。
しかし今はconsole.logを使ってsingleDocumentの中身を表示しているだけなので、実際に表示できるよう次のコードを追加してください。
const SingleBlog = async (props) => {
const { singleDocument } = await getSingleBlog(props);
return (
<div>
<h1>{singleDocument.data.title}</h1>
<p>{singleDocument.data.date}</p>
<reactMarkdown>{singleDocument.content}</reactMarkdown>
</div>
);
}
またこのファイルの上部にreact-markdownをimportしておいてください。そして追加したコードのtitleやdateはmdファイルのものに対応していまsingleDocumet.contentはフロントマターでない部分を表示しています。これで個別記事を表示する機能が完成しました。あとは今記事の順番がばらばらなので整えてあげましょう。/blog/page.jsで以下のコードをgetAllBlogsに追加してください。
})
const orderedBlogs = blogs.sort((a, b) => {
return b.frontmatter.id - a.frontmatter.id
})
return {
blogs: orderedBlogs
}
}
このコードを書くことで記事を降順に並べることができます。blogsをソートする仕組みをorderdBlogsと定義し、引数をa,bとします。2つの記事のIDを引き算し、負の数であればaをbより前、0ならそのまま、生の数であればbをaの前に配置します。例えばaのidが1、bのidが3であれば3-1=2と生の数になるため、aの前にbを配置します。これを繰り返して記事を降順に並べるのです。これで個別のブログ記事を整列させて表示することができるようになりました!
本来ならこの後CSSの適用や画像の挿入などの説明をしていくのですが、今回はブログサイトの仕組みを学習することが目的だったのでここでは説明を省きます。完成版を作ってみたい人はぜひ書籍を見てみてください!
まとめ
今回の記事作成はとてもいいアウトプットの機会になりました。next.jsでどんなことができるのか、大まかには理解できたという実感を得られました。そしてこの書籍の成果物作成にあたって、さまざまなエラーが発生していまいましたが、やはり重要なことはエラーログをしっかりと理解して仮説をたてること、そしてバージョンを確認することだと学びました。