📌

Next.jsの基礎知識

2022/02/25に公開

概要

Next.jsに関する基礎知識を解説してみます。

Next.jsのメリット

Server-side Rendering(SSR)のビルトイン

通常のReactアプリの場合、HTMLを表示すると<div id="root"></div>といったエントリポイントになるHTMLのみが表示されます。
Reactはクライアントサイドライブラリなので、全てのレンダリングはブラウザ上で行われるためです。
画面をリロードすると一瞬白いページが見えてちらつきが発生します。
また、SEOの観点では、検索エンジンは空のページのみを返してしまい検索エンジンに取り込まれない問題があります。
SSRを用いることで、サーバ側がそのURLの表示に対して生成されたHTMLをレスポンスすることができます。
Next.jsにはSSR機能が組み込まれています。

File-based Routing

ReactではURLが切り替わる時、ブラウザの標準動作を抑止し、別のコンポーネントを表示させる動きをします。この場合、Reactではコンポーネント用のファイルを用意し、App.js内にReact Routerを用いて、URLに対するルーティングを記載しますが、Next.jsではファイルの配置ルール/命名規則によって表現します。

URLに関する動きの差異整理

  • Reactのみ:URLによるページだし分けの概念がない。HTMLはエントリポイントのタグのみ。
  • React+ReactRouter:URLによりページが出しわけられる。HTMLはエントリポイントのタグのみ。
  • Next.js:URLによりページが出しわけられる。URL直打ちするとレスポンスにHTMLが含まれている。

Fullstack Capabilities

node.jsのバックエンドのコード追加が可能になります。
データベースへのアクセスや認証などの機能を追加できるようになります。

Next.jsの開始

ディレクトリ構成

create next-appすると以下のようなディレクトリ構成になります。
Reactの場合と大きく異なるのが、File-based Routingのために、pagesディレクトリ配下に公開URLに合わせてファイルを配置していく形となっている点です。

.
┝ node_modules
┝ pages
│ ┝ api
│ ┝ _app.js
│ └ index.js
┝ public
┝ styles
┝ .gitignore
┝ package-lock.json
┝ package.json
└ README.md

URLとの紐づき(pages配下)

例えばpages/news.jsにファイルを配置すると、ブラウザで/newsにアクセスするとページが表示されます。
pages/news/index.jsであれば/news/pages/news/test.jsであれば/news/testでページが表示されます。

動的URL

例えばニュースやブログの詳細ページのように、1ページずつ個別のページを作るのではなくて動的にページが生成される場合、pages/[newsId].jsといった形で、ファイル名に[]に囲まれた名前を付けることで、URLから動的にパラメータを受け取ることができるようになります。

動的に受け取ったパラメータは、次のようなコードでJSの処理内で受け取ることができます。

pages/[newsId].js
import { useRouter } from "next/router";

function DetailPage() {
  const router = useRouter();
  // パラメータを受け取る
  const newsId = router.query.newsId;
  console.log(newsId);
  return <h1>The Detail Page</h1>;
}
export default DetailPage;

Client Side Navigation

通常Webサイトではaタグで<a href="xx">リンク</a>のようにリンクを作成しますが、この場合、ブラウザは画面遷移するたびに画面を読み込みなおします。
これはユーザ体験としてはあまり良いものではなく、画面の一部のみ変更が必要な場合は、(URLについても、見た目上変化させつつ)再読み込みせずに次の画面に遷移できることが望ましいです。
さらに、画面遷移してからそのURLで画面をリロードしたとしても、正しくページが返ってくる必要があります。これは、Client Side Navigationという技術で実現できます。
なぜ、再読み込みしないのに画面が遷移できるかというと、Code splitting(JSコードの自動分割)とprefetching(コンポーネントの事前生成)という技術を用いて、実際にユーザが操作するよりも前に遷移先のコンポーネントを裏側で生成しているためです。
具体的にはLinkコンポーネントを用いて次のように実装します。

javascript(ファイル先頭)
import Link from "next/link";
javascript(リンク箇所)
<Link href="xx">リンク</Link>

これにより、画面を再読み込みせずに画面遷移できるようになります。(Next.jsで管理されている内部リンクのみです)

コンポーネントへのpropsでの値の受け渡し

親コンポーネントから子コンポーネントに対して、propsを用いて値の引き渡すを行うことができます。
例えば、ニュース一覧を表示するNewsというコンポーネントを作成し、そこにニュース一覧のリスト配列を引き渡す場合、コンポーネントの呼び出しタグに属性として変数を渡すことで、子コンポーネントに渡すことができます。

index.js
import News from "../components/News";

const news = [
  {
    id: "1",
    title: "test1",
    content: "texttext1",
  },
  {
    id: "2",
    title: "test2",
    content: "texttext2",
  },
];

function HomePage() {
  return <News news={news} />;
}

export default HomePage;
News.js
function News(props) {
  return (
    <div>
      <ul>
        {props.news.map((item) => {
          return (
            <li>
              {item.id}:{item.title}:{item.content}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

export default News;
出力されるHTML
<div>
  <ul>
    <li>1:test1:texttext1</li>
    <li>2:test2:texttext2</li>
  </ul>
</div>

コンポーネントへのpropsでの関数の受け渡し

コンポーネントに対しては値だけでなく関数も引き渡すことができます。
親コンポーネント側で関数処理を定義しておき、同じようにpropsを用いて関数を渡しています。
子コンポーネント側でその関数を呼び出すイベントが発生すると、親コンポーネント側で定義した関数が実行されます。

index.js
import TestForm from "../../components/TestForm";

function FormPage() {
  // 子コンポーネント側に引き渡す関数の定義
  function sendData(enteredData) {
    // 子コンポーネント側から関数が呼ばれると実行される処理
    console.log(enteredData);
  }
  return <TestForm sendData={sendData} />;
}

export default FormPage;
TestForm.js
import { useRef } from "react";

function TestForm(props) {
  // インプット要素への参照をuseRef Hooksを用いて定義
  const titleInputRef = useRef();
  const contentInputRef = useRef();

  // formのsubmitが行われると呼ばれる関数を定義
  function submitHandler(event) {
    event.preventDefault();

    // インプット要素から値を取得
    const enteredTitle = titleInputRef.current.value;
    const enteredContent = contentInputRef.current.value;

    const contactData = {
      title: enteredTitle,
      content: enteredContent,
    };

    // 親コンポーネントからpropsで渡された関数を実行
    props.sendData(contactData);
  }

  return (
    <form onSubmit={submitHandler}>
      <div>
        <label htmlFor="title">Title</label>
        <input type="text" required id="title" ref={titleInputRef}></input>
        <label htmlFor="content">Content</label>
        <input type="text" required id="content" ref={contentInputRef}></input>
        <input type="submit" />
      </div>
    </form>
  );
}

export default TestForm;

CSS Modulesの利用

コンポーネントに対してスタイルシートを適用する場合、そのままclassを記載するのではなく、className属性に対して設定を受け渡す形になります。
また、その際には、CSSをモジュールのように引き渡すことができる、CSS Modulesを利用することができます。
CSS Modulesは、*.module.cssといったファイル名でCSS定義を用意しておき、それをオブジェクトとしてimportして、CSSのクラス情報を、オブジェクトの属性として扱い、そのままコンポーネントのclassNameに引き渡すことができます。
次のような使い方になります。

test.module.css
.text {
  color: red;
}
sample.js
import cssTest from "../styles/test.module.css";

function SamplePage() {
  return <p className={cssTest.text}>サンプル文字列</p>;
}

export default SamplePage;

上記により、<p class="text">を設定したのと同じ状態になり、実際のスタイル(文字色)も適用されます。

このCSS Moduleの.textといったクラスは、モジュール化されており(=他と分けて制御されるようになっており)、名前の衝突によって予期しないスタイル崩れなどが起こらないようになっています。(クラス名が衝突しないように自動制御されます)

この技術によって、コンポーネント内でスタイルを独立性をもって管理/制御できるようになっています。

Layoutコンポーネントの利用

コンポーネントは、コンポーネントタグで子要素を囲むことで、他のコンポーネントを巻き付けるような形で利用することができます。
その場合、propsには、特別な変数props.childrenの中に子要素が格納された状態で、コンポーネントが呼び出されます。
次のように利用することができます。

レイアウト用のコンポーネント

Layout.js
function Layout(props) {
  return (
    <div>
      <h1>Header</h1>
      <main>{props.children}</main>
      <h2>Footer</h2>
    </div>
  );
}

export default Layout;

レイアウトを利用するページコンポーネント

sample.js
import Layout from "../components/layout/Layout";

function SamplePage() {
  return (
    <Layout>
      <div>Content</div>
    </Layout>
  );
}

export default SamplePage;
出力されるHTML
<div>
  <h1>Header</h1>
    <main>
      <div>Content</div>
    </main>
  <h2>Footer</h2>
</div>

ルートコンポーネント(_app.js)の利用

全てのページに対して共通的な設定を行う場合、クライアント処理を行う、特殊な共通コンポーネント(ルートコンポーネント)のpages/_app.jsを用いて制御を行います。

pages/_app.js
import "../styles/globals.css";

function MyApp({ Component, pageProps }) {
  return <Component {...pageProps} />;
}

export default MyApp;

ルートコンポーネント(_app.js)は、ページナビゲーション(遷移)が行われるたびに、対象ページのコンポーネント一式がComponentにセットされ、ページに渡されたpropspagePropsに設定された状態で必ず呼び出されます。
そのため、このルートコンポーネントに対して全体共通で適用するLayout等を適用しておくと、すべてのページコンポーネントで共通的に呼び出される構成を作ることができます。

Layout.js
function Layout(props) {
  return (
    <div>
      <h1>Header</h1>
      <main>{props.children}</main>
      <h2>Footer</h2>
    </div>
  );
}
sample.js
function SamplePage() {
  return <p>ページコンテンツ</p>;
}

export default SamplePage;
出力されるHTML
<div>
  <h1>Header</h1>
    <main>
      <p>ページコンテンツ</p>
    </main>
  <h2>Footer</h2>
</div>

headタグ内の情報をHeadコンポーネントで設定する

各ページのtitlemeta descriptionなど、通常headタグ内に出力する内容は、<Head>コンポーネントを用いることで、各ページに設定することができます。

設定方法は次のようになります。

index.js
import Head from "next/head";

function HomePage() {
  return (
    <>
      <Head>
        <title>This is Title</title>
        <meta name="description" content="This is description" />
      </Head>
      <h1>Hello World</h1>
    </>
  );
}

export default HomePage;

Router push/replaceを用いた画面遷移処理呼び出し

画面遷移するときは、<Link>コンポーネントでページナビゲーションすることができますが、処理内でリダイレクトのように画面遷移する場合、next/routerを用いて画面遷移することができます。

router.pushを用いた例

sample.js
import { useRouter } from "next/router";

function SamplePage() {
  const router = useRouter();

  // ボタンを押下したら、/news/ページに遷移させる
  function movePageHandler() {
    router.push("/news/");
  }

  return (
    <button type="button" onClick={movePageHandler}>
      Click me
    </button>
  );
}

export default SamplePage;

この場合、ブラウザのwindow.historyに追加pushがされます。つまり、ブラウザの戻るボタンに履歴が追加され、戻るボタン押下時にこのページに戻ってくることができます。(リダイレクトに近いです)

一方で、router.replaceを用いると、履歴を置換するので、このページにはブラウザの戻るボタンで戻って来られません。(このページに遷移した履歴はなかったことになります。)(イメージ的にはフォワード処理に近いです)

router.replaceを用いた例

sample.js
import { useRouter } from "next/router";

function SamplePage() {
  const router = useRouter();

  // ボタンを押下したら、/news/ページに遷移させる
  // ブラウザの履歴が上書かれるので、戻るボタンでこのページには戻ってこられない
  function movePageHandler() {
    router.replace("/news/");
  }

  return (
    <button type="button" onClick={movePageHandler}>
      Click me
    </button>
  );
}

export default SamplePage;

なお、外部ページに飛ぶ場合は、window.location.hrefで飛ぶことと同義です。

処理内で起こす画面遷移アクションの整理

  • router.push:ブラウザに履歴を残しつつページナビゲーションで遷移する。
  • router.replace:ブラウザに履歴を上書いてページナビゲーションで遷移する。
  • window.location.href:ページごとリロードして遷移する。

useStateとuseEffectを用いて非同期に画面を更新する

外部のAPIなどからデータを取得してきて表示することはよくあると思います。
以下の例では、useStateuseEffectを持ちいて、画面の初期表示直後に、ニュース一覧データを取得して、画面を更新しています。
const [news, setNews] = useState([])で、空の配列[]を変数newsに設定し、news変数の状態監視を開始します。
useEffect(() => { setNews(dummyNewsList); },[])で、最初のレンダリングが終わった直後に、setNews関数を呼び出しています。
useEffectは初回レンダリング時及び、レンダリングが更新される都度、その直後に後追いで呼ばれますが、第2引数に、2回目以降のレンダリング時に関数が呼ばれる条件として監視する変数を設定できます。つまり、[]指定は、初回レンダリングのみアクションを起こすことを意味します。

index.js
import { useState, useEffect } from "react";
import News from "../components/News";

const dummyNewsList = [
  {
    id: "1",
    title: "test1",
    content: "texttext1",
  },
  {
    id: "2",
    title: "test2",
    content: "texttext2",
  },
];

function HomePage() {
  const [news, setNews] = useState([]);

  useEffect(() => {
    setNews(dummyNewsList);
  }, []);

  return <News news={news} />;
}

export default HomePage;
News.js
function News(props) {
  return (
    <div>
      <ul>
        {props.news.map((item) => {
          return (
            <li>
              {item.id}:{item.title}:{item.content}
            </li>
          );
        })}
      </ul>
    </div>
  );
}

export default News;

この場合、サーバ側からSSRでレスポンスされた瞬間には以下のようなHTMLになっています。

html(画面表示直後)
<div>
  <ul>
  </ul>
</div>

このレンダリングが行われた直後に、useEffectが動作し、次のような状態にレンダリングの更新が1度だけ行われます。

html(再レンダリング後)
<div>
  <ul>
    <li>1:test1:texttext1</li>
    <li>2:test2:texttext2</li>
  </ul>
</div>

これは、見た目上画面表示はされますが、SEO的には検索エンジンにインデックスされないため、あまり望ましい結果ではありません。(後追いでレンダリングすることが悪いわけではなく、検索エンジンに読み込ませたいコンテンツの場合には、向いていないという意味です)

Page Pre-Renderingを用いてサーバサイドで事前にレンダリングしておく

Next.jsの重要な機能となりますが、上記のような外部からデータ取得した場合のSEOとレンダリングの問題を解決するために、主に以下の選択肢が存在します。
なお、これは常にページ全体といったことはなく、混在することができます。

  1. Static Generation(静的生成)
  2. Server-side Rendering(サーバサイドレンダリング)

上記はレンダリングが行われるタイミングが異なります。

Static Generation(静的生成)

アプリケーションがビルドされるタイミングで1度だけ処理が呼ばれ、それ以降は生成されたものを参照し続けます。
更新する場合は、アプリケーションを再ビルドする必要があります。

Server-side Rendering(サーバサイドレンダリング)

サーバサイド処理が呼ばれるタイミングで都度処理が実行されます。

静的生成のためにgetStaticPropsを使う

ページコンポーネントでは、getStaticPropsを用いることで、ビルドプロセス時にのみデータを取得することが可能です。
getStaticPropsで定義した処理は、都度表示時には呼ばれることはなく、また、クライアント側にはコードも公開されません。
事前に外部APIやDBなどからデータを取得しておき、都度表示時にはその結果のみを使用して処理を行います。
また、getStaticPropspropsを返却returnしており、それは、ページコンポーネントの引数propsとして、HomePage(props)のようにページ初期表示処理に引き渡されます。

index.js
import { useState, useEffect } from "react";
import News from "../components/News";

function HomePage(props) {
  return <News news={props.news} />;
}

export async function getStaticProps() {
  const dummyNewsList = [
    {
      id: "1",
      title: "test1",
      content: "texttext1",
    },
    {
      id: "2",
      title: "test2",
      content: "texttext2",
    },
  ];

  return {
    props: {
      news: dummyNewsList,
    },
  };
}

export default HomePage;

これによって、ブラウザ表示時には、最初からHTMLが組み込まれた状態になり、また、(再ビルドまでは)都度データ取得処理が動くこともありません。

html(初期表示時点 ※再レンダリング無し)
<div>
  <ul>
    <li>1:test1:texttext1</li>
    <li>2:test2:texttext2</li>
  </ul>
</div>

ただし、これにはまだ課題があり、ビルドしてから時間がたつとデータが古くなってしまう可能性があるという点です。

revalidateで指定した秒数ごとにキャッシュを更新する

getStaticPropsrevalidateオプションをつけることで、再ビルドを待たずに、指定秒数以上経過したアクセスをトリガとして、getStaticPropsのデータを更新します。

その場合、getStaticPropsreturnrevalidate: 10の設定をつかします。数字は秒数でこの設定は10秒キャッシュを使うことを意味します、1時間キャッシュを使う場合は3600になります。
この値は取得するデータの性質によって変える必要があります。

index.js
import { useState, useEffect } from "react";
import News from "../components/News";

function HomePage(props) {
  return <News news={props.news} />;
}

export async function getStaticProps() {
  const dummyNewsList = [
    {
      id: "1",
      title: "test1",
      content: "texttext1",
    },
    {
      id: "2",
      title: "test2",
      content: "texttext2",
    },
  ];

  return {
    props: {
      news: dummyNewsList,
    },
    revalidate: 10,
  };
}

export default HomePage;

Server-side RenderingでgetServerSidePropsを用いてサーバサイドのみで都度処理をする

getServerSidePropsを用いることで、リクエストがある都度サーバサイドで実行する処理を記載することができます。
getServerSidePropsの中ではcontextを取得でき、reqやresといった、リクエスト/レスポンスに関するオブジェクトも扱うことができます。

index.js
import { useState, useEffect } from "react";
import News from "../components/News";

function HomePage(props) {
  return <News news={props.news} />;
}

export async function getServerSideProps(context) {
  const req = context.req;
  const res = context.res;
  const dummyNewsList = [
    {
      id: "1",
      title: "test1",
      content: "texttext1",
    },
    {
      id: "2",
      title: "test2",
      content: "texttext2",
    },
  ];

  return {
    props: {
      news: dummyNewsList,
    },
  };
}

export default HomePage;

一見非常に便利なのですが、ただ、SSRが本当に必要な場面であるかはよく考える必要があります。(要するにやたらに使うべきでないです)
なぜなら、サーバサイドでレンダリングする意味は、SEO対策として検索エンジンにインデックスさせるためであり、認証状態やリクエスト、Cookieなどによって動的に変化するマイページのようなページは、そもそもSEO対策をする意味がない可能性が高いためです。
Next.jsにおいてはクライアントサイドレンダリングがReactの仕組みで普通に行えるので、認証が必要なAPIなどは、クライアントサイド側で呼んで(つまり、getStaticPropsgetServerSideProps以外の場所で、hooks等を用いて呼んで)クライアントサイドでだし分けをすれば、たいていの場合は問題がないためです。

動的URLのページをgetStaticPathsとgetStaticPropsで事前に静的生成する

URLがデータによって動的に変化するページ、例えばニュース詳細ページやブログの詳細ページにおいて、Static Generation(静的生成)を用いるために、事前に動的URLの一覧を作成(getStaticPaths)して、ビルド時に事前に静的生成を行っておきます。
全ページを事前に静的生成しておき、再ビルドまで更新しない場合は、getStaticPathsreturnfallback=falseを指定します。
また、ビルド後にページが追加された場合(=事前生成されていない動的パラメータでページが呼ばれた場合)、getStaticPathsreturnfallback=trueを指定することで、差分のページについて随時getStaticPropsを実行することもできます。

なお、動的URLにする場合、pages/[newsId]/index.jsのようにファイルパスの中に[]で囲ったパラメータ文字列を含めておくことで、JS処理側でparamとして取得できます。

動的URLの対象ページをビルド時に全ページ作成しておく場合

pages/[newsId]/index.js
function NewsDetail(props) {
  return (
    <div>
      <div>{props.id}</div>
      <div>{props.title}</div>
      <div>{props.content}</div>
    </div>
  );
}

export async function getStaticPaths() {
  return {
    fallback: false,
    paths: [
      {
        params: {
          newsId: "n1",
        },
      },
      {
        params: {
          newsId: "n2",
        },
      },
    ],
  };
}

export async function getStaticProps(context) {
  const newsId = context.params.newsId;

  const newsData = {
    id: newsId,
    title: "title1",
    content: "text",
  };

  return { props: newsData };
}

export default NewsDetail;

動的URLの対象ページがビルド時に増えた場合随時ページ作成(static generation)する場合

pages/[newsId]/index.js
function NewsDetail(props) {
  return (
    <div>
      <div>{props.id}</div>
      <div>{props.title}</div>
      <div>{props.content}</div>
    </div>
  );
}

export async function getStaticPaths() {
  return {
    fallback: false,
    paths: [
      {
        params: {
          newsId: "n1",
        },
      },
      {
        params: {
          newsId: "n2",
        },
      },
    ],
  };
}

export async function getStaticProps(context) {
  const newsId = context.params.newsId;

  const newsData = {
    id: newsId,
    title: "title1",
    content: "text",
  };

  return { props: newsData };
}

export default NewsDetail;

ここに、先述のgetStaticPropsreturnでのrevalidate指定を組み合わせることで、ページの生成や更新のタイミングをコントロールすることができます。

ページから呼び出すAPIを作る

Next.jsでは、/api/フォルダに配下にAPIを作成することができます。
このコードは完全にサーバ側で実行され、クライアント側に公開されることはありません。
そのため、DBやAPIへの非公開の接続情報などを扱うことができます。
(クライアントサイド側の処理に秘匿情報を書いてはいけません)

APIでは以下のような形で記載を行えます。

pages/api/hello.js
export default function handler(req, res) {
  res.status(200).json({ name: "Hello World!" });
}

このAPIのエンドポイントURLは/api/helloとなります。

また、クライアント側では、そのAPIを呼び出す形で処理を記載します。
たとえば、フォームに入力された値をAPIに送信する処理は次のような実装になります。

フォーム画面のページコンポーネント

pages/form/index.js
import TestForm from "../../components/TestForm";

function FormPage() {
  async function sendData(enteredData) {
    const response = await fetch("/api/post", {
      method: "POST",
      body: JSON.stringify(enteredData),
      headers: {
        "Content-Type": "application/json",
      },
    });
    const data = await response.json();
    console.log(JSON.stringify(data));
  }
  return <TestForm sendData={sendData} />;
}

export default FormPage;

入力フォームコンポーネント

components/TestForm.js
import { useRef } from "react";

function TestForm(props) {
  // インプット要素への参照をuseRef Hooksを用いて定義
  const titleInputRef = useRef();
  const contentInputRef = useRef();

  // formのsubmitが行われると呼ばれる関数を定義
  function submitHandler(event) {
    event.preventDefault();

    // インプット要素から値を取得
    const enteredTitle = titleInputRef.current.value;
    const enteredContent = contentInputRef.current.value;

    const contactData = {
      title: enteredTitle,
      content: enteredContent,
    };

    // 親コンポーネントからpropsで渡された関数を実行
    props.sendData(contactData);
  }

  return (
    <form onSubmit={submitHandler}>
      <div>
        <label htmlFor="title">Title</label>
        <input type="text" required id="title" ref={titleInputRef}></input>
        <label htmlFor="content">Content</label>
        <input type="text" required id="content" ref={contentInputRef}></input>
        <input type="submit" />
      </div>
    </form>
  );
}

export default TestForm;

フォーム入力内容を受け取って処理するAPI

api/post.js
export default function handler(req, res) {
  if (req.method === "POST") {
    const data = req.body;

    // DBやAPIに接続/参照/更新などをこなうプログラムを記載

    res.status(200).json({ result: "OK" });
  }
}

DBへの接続情報など秘匿情報を使用できる個所(サーバサイドのみで動く箇所)の整理
コード内で主に以下の箇所はサーバサイドのみで動作します。

  • getStaticPaths()
  • getStaticProps()
  • getServerSideProps()
  • /api/配下のサーバサイドAPI処理

本番用にプロジェクトをビルドする

実際にプロジェクトが完成したら、本番用にビルドを行い開始します。

本番用にビルドする
以下のコマンドで本番用にビルドできます。
静的ファイル生成などが行われます。

shell
yarn build

本番用サーバを開始する
以下のコマンドでビルドしたプロジェクトを開始します。

shell
yarn start

まとめ

React、Next.jsは現在大変速いペースで機能追加され進化しています。

フロントエンドで非常に人気が高くなっていますが、
常に情報をアップデートしないと、すぐに情報が古くなってしまいますね。

今後の動向にも注目していきたいと思います。

Discussion