Next.js TypeScriptチュートリアル

ここではNext.jsのサンプルアプリケーションを作成します。言語としては TypeScript 、CSS-in-JSのライブラリとして styled-components を使います。

コードはこちらに配置しています。この記事の Next.js のバージョンは7.0.2時点の情報を元に記述されています。

サンプル@GitHub

# 下記コマンドで起動します。

npm install
# もしくは yarn

npm run dev
# もしくは yarn dev

もくじ

インドとタイのカレー (curry) を紹介する本当にミニマムな架空サイトを制作することを通して、Next.jsでのサイト制作を紹介します。

Next.jsを使用しているので、各ページはデフォルトでサーバーサイドレンダリングされます。さらに、上部の「印度」と記述してあるテキストをクリックすると、トップページ (「/」)へ、泰國と記述してあるテキストをクリックすると (「/thai」) というページにクライアント側のルーティングを使ってリンクします。

Next.jsサンプルイメージ

サンプルでは2つのページを作成します。

  • ※ インドカレーの紹介ページ 「/」
  • ※ タイカレーの紹介ページ「/thai」

最終的なディレクトリ構成は下記のような形となります。

Reactアプリディレクトリ構成

ライブラリをインストールする

必要なライブラリをインストールします。今回はTypeScriptを使うとともに、styled-componentsを使いますので、Reactの他に、これらのライブラリをインストールします。

/* package.json */

...
"dependencies": {
    "isomorphic-unfetch": "^3.0.0",
    "next": "^7.0.2",
    "react": "^16.7.0",
    "react-dom": "^16.7.0",
    "styled-components": "^4.1.3"
  },
  "devDependencies": {
    "@types/next": "^7.0.6",
    "@types/react": "^16.7.18",
    "@types/react-dom": "^16.0.11",
    "@types/styled-components": "^4.1.6",
    "@zeit/next-typescript": "^1.1.1"
  }
...

pakcage.jsonに上記のライブラリを記述したあと、下記のコマンドでライブラリ群をインストールします。

npm install
# or yarn

@zeit/next-typescript ライブラリをインストールしたことにより、TypeScriptでNext.jsを記述できるようになったので、 .babelrc ファイルを記述して、Next.jsのビルド設定をカスタマイズします。

.babelrcを作成して、ビルド設定を編集する

Next.jsでは .babelrc を作成して、編集することでビルド設定を変更することができます。ここでは、Next.jsをTypeScriptを使ってビルドしますので、その設定を .babelrc に記述します。

/* .babelrc */
{
  "presets": ["next/babel", "@zeit/next-typescript/babel"],
  "plugins": []
}

next.config.jsを作成・編集してNext.jsの設定を変更する

next.config.js を記述することで、Next.js全般の設定を変更することができます。ここでは上記のBabelの設定に加えて、TypeScriptへの対応を行います。また、 powerdByHeaderをfalseにすることで、レスポンスヘッダからX-Powered-Byヘッダを取り除いています。(クライアントにバージョンを知らせない。)

/* next.config.js */

const withTypescript = require("@zeit/next-typescript");
module.exports = withTypescript({
  poweredByHeader: false
});

_document.tsxの作成とstyled-componentsのサーバーサイドレンダリング

_document.tsx を作成して共通のドキュメント部分を作ります。この_document.tsxは サーバーサイドのみで実行 されます。ここで、styled-componentsのサーバーサイドレンダリングを実行します。styled-componentsのサーバーサイドレンダリングに関しての詳細は下記のページを参照してください。

styled-componentsのSSR

/* pages/_document.tsx */
import React from "react";
import Document, { Head, Main, NextScript } from "next/document";
import { ServerStyleSheet } from "styled-components";

/* 
  カスタムドキュメント
  ※ サーバーサイド側 (Node.js側) でのみ実行される。
  ※ https://nextjs.org/docs/#custom-document
*/
export default class MyDocument extends Document {
  static async getInitialProps(ctx: any) {
    // styled-componentsをサーバーサイドレンダリング
    // 詳細は下記を参照してください。
    // https://www.styled-components.com/docs/advanced#server-side-rendering

    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;
    ctx.renderPage = () =>
      originalRenderPage({
        enhanceApp: (App: any) => (props: any) =>
          sheet.collectStyles(<App {...props} />)
      });

    const initialProps = await Document.getInitialProps(ctx);
    return {
      ...initialProps,
      styles: [...(initialProps.styles as any), ...sheet.getStyleElement()]
    };
  }

  public render() {
    return (
      <html lang="ja">
        <Head>
          <meta charSet="UTF-8" />
          <meta httpEquiv="X-UA-Compatible" content="IE=edge" />
          <meta name="viewport" content="width=device-width,initial-scale=1" />
          <meta name="author" content="Curry Lover" />
          <link rel="stylesheet" href="/static/reset.css" />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </html>
    );
  }
}

_app.tsxの作成とページ間共通部品の作成

_app.tsx を作成することでカスタムの共通レイアウトを作成します。ここでは、どのページでも表示される上部ナビゲーションバーを作成します。

/* pages/_app.tsx */
import React from "react";
import App, { Container } from "next/app";
import { GlobalNav } from "../components/GlobalNav";

/* 
  カスタム共通処理
  ※ サーバーサイド側とクライアント側、両方で実行される
  ※ https://nextjs.org/docs/#custom-app
*/
export default class MyApp extends App {
  static async getInitialProps(ctx: any) {
    let pageProps = {};

    if (ctx.Component.getInitialProps) {
      pageProps = await ctx.Component.getInitialProps(ctx);
    }

    return { pageProps };
  }

  render() {
    const { Component, pageProps } = this.props;

    return (
      <Container>
        <GlobalNav />
        <Component {...pageProps} />
      </Container>
    );
  }
}

Next.jsで静的アセットを配信する

Next.js ではデフォルトでは staticディレクトリ 以下に画像など静的ファイルを置くことで、Node.js経由で静的ファイルを配信することができます。今回は手軽な例としてこの機能を使いますが、本格的なアプリケーションではNode.js (Next.js) をアプリケーション処理に特化させ、 Nginx や各種CDNなどに静的ファイル配信を任せる構成が一般的かと思います。サンプルアプリでは、「/static/curry1.jpg」、「/static/curry7.jpg」というようにカレーの画像を12個と reset.css を配置しています。

React.js画像ディレクトリ

各ページを作成する

「pages/index.tsx」では static async getInitialProps() でインドカレーの画像群を返すようにします。本来的には static async getInitialProps() では外部APIサーバーから非同期に取得してきたデータを使用するのですが、ここでは簡便化のため、直接JSONデータを返すことにしています。

/* pages/index.tsx */

import * as React from "react";

import { ICurry } from "../models/Curry";
import { CurryList } from "../components/CurryList";
import { MainTitle, MainContent } from "../styled/Page";
// import fetch from "isomorphic-unfetch";

interface IProps {
  curries: ICurry[];
}

export default class BlogsPage extends React.Component<IProps> {
  static async getInitialProps(ctx: any) {
    try {
      // const response = await fetch('https://??????.???/curries/india');
      // const json = await response.json();

      // 通常では上記のように外部APIサーバーに対してデータを取得しにいきますが、今回は簡潔に済ますために
      // static async getInitialProps() で直接データを returnすることにします。
      // 下記のデータがAPIサーバーから返ってくると想定して、進めます。
      // 画像はpixabay様の著作権フリー・帰属表示不要の画像を使っています。
      // https://pixabay.com/ja/
      const json: ICurry[] = [
        {
          id: 1,
          name: "Curry1",
          imageUrl: "/static/curry1.jpg"
        },
        {
          id: 2,
          name: "Curry2",
          imageUrl: "/static/curry2.jpg"
        },
        {
          id: 3,
          name: "Curry3",
          imageUrl: "/static/curry3.jpg"
        },
        {
          id: 4,
          name: "Curry4",
          imageUrl: "/static/curry4.jpg"
        },
        {
          id: 5,
          name: "Curry5",
          imageUrl: "/static/curry5.jpg"
        },
        {
          id: 6,
          name: "Curry6",
          imageUrl: "/static/curry6.jpg"
        }
      ];

      return {
        curries: json
      };
    } catch (e) {
      console.error(e);
      return {
        curries: []
      };
    }
  }

  public render() {
    return (
      <MainContent>
        <MainTitle>Indian Curries</MainTitle>
        <CurryList curries={this.props.curries} />
      </MainContent>
    );
  }
}

次は 「/thai」というURLを叩いた時に表示される、コンポーネントを作成します。

/* pages/thai.tsx */

import * as React from "react";

import { ICurry } from "../models/Curry";
import { CurryList } from "../components/CurryList";
import { MainContent, MainTitle } from "../styled/Page";
// import fetch from "isomorphic-unfetch";

interface IProps {
  curries: ICurry[];
}

export default class BlogsPage extends React.Component<IProps> {
  static async getInitialProps(ctx: any) {
    try {
      // const response = await fetch('https://??????.???/curries/thailand');
      // const json = await response.json();

      // 通常では上記のように外部APIサーバーに対してデータを取得しにいきますが、今回は簡潔に済ますために
      // static async getInitialProps() で直接データを returnすることにします。
      // 下記のデータがAPIサーバーから返ってくると想定して、進めます。

      const json: ICurry[] = [
        {
          id: 7,
          name: "Curry7",
          imageUrl: "/static/curry7.jpg"
        },
        {
          id: 8,
          name: "Curry8",
          imageUrl: "/static/curry8.jpg"
        },
        {
          id: 9,
          name: "Curry9",
          imageUrl: "/static/curry9.jpg"
        },
        {
          id: 10,
          name: "Curry10",
          imageUrl: "/static/curry10.jpg"
        },
        {
          id: 11,
          name: "Curry11",
          imageUrl: "/static/curry11.jpg"
        },
        {
          id: 12,
          name: "Curry12",
          imageUrl: "/static/curry12.jpg"
        }
      ];

      return {
        curries: json
      };
    } catch (e) {
      console.error(e);
      return {
        curries: []
      };
    }
  }

  public render() {
    return (
      <MainContent>
        <MainTitle>Thailand Curries</MainTitle>
        <CurryList curries={this.props.curries} />
      </MainContent>
    );
  }
}

それぞれのURL、「/」と「/thai」を叩いた時に、 static async getInitialProps() からデータが返され、それを元に、ページがサーバーサイドレンダリングされます。

ページ間で再利用するコンポーネントやロジック・モデルを定義する。

page ディレクトリ以外にページ間で使い回す、コンポーネント群やロジック、モデルなどを配置していきます。

モデル

モデルを定義します。ICurryというカレーのインターフェースを定義します。

export interface ICurry {
  id: number;
  name: string;
  imageUrl: string;
}

コンポーネント群

CurryListコンポーネントというReactコンポーネントを定義します。上記のICurryの配列をこのReactコンポーネントに渡すと、リストとして表示してくれるコンポーネントです。

/* components/CurryList.tsx */

import { ICurry } from "../models/Curry";
import styled from "styled-components";

const CurryUl = styled.ul`
  align-items: center;
  display: flex;
  flex-wrap: wrap;
`;

const CurryLi = styled.li`
  margin: 12px;
`;

const CurryImg = styled.img`
  display: block;
  width: 300px;
  height: 300px;
  object-fit: cover;
`;

const CurryName = styled.span`
  color: #e65100;
  font-size: 14px;
`;

export const CurryList = (props: { curries: ICurry[] }) => (
  <CurryUl>
    {props.curries.map(curry => (
      <CurryLi>
        <CurryName>{curry.name}</CurryName>
        <CurryImg src={curry.imageUrl} />
      </CurryLi>
    ))}
  </CurryUl>
);

次はすべてのページの上部に表示されるグローバルナビゲーションコンポーネントを作成します。全ページで表示される共通のReactコンポーネントとして、 _app.tsx で呼び出されます。

/* components/GlobalNav.tsx*/

import styled from "styled-components";
import Link from "next/link";

const Header = styled.header`
  align-items: center;
  display: flex;
  height: 50px;
  padding: 0 24px;
  background-color: #5a170e;
`;

const Title = styled.h1`
  font-size: 20px;
  color: #e65100;
`;

const Nav = styled.nav`
  flex-grow: 1;
  padding: 0 40px;
`;

const Ul = styled.ul`
  align-items: center;
  display: flex;
`;

const Li = styled.li`
  margin: 0 8px;
`;

const LinkText = styled.span`
  color: #ffd202;
  cursor: pointer;
  font-size: 12px;
  padding: 4px 8px;
  user-select: none;
`;

export const GlobalNav = () => (
  <Header>
    <Title>Curry World</Title>
    <Nav>
      <Ul>
        <Li>
          <Link href="/">
            <LinkText>印度</LinkText>
          </Link>
        </Li>
        <Li>
          <Link href="/thai">
            <LinkText>泰國</LinkText>
          </Link>
        </Li>
      </Ul>
    </Nav>
  </Header>
);

あとは、各ページで呼び出される、styled-componentsを使用して作ったコンポーネントを定義します。

/* styled/Page.ts */
import styled from "styled-components";

export const MainContent = styled.main`
  background-color: #f8f8f8;
  padding: 24px;
`;

export const MainTitle = styled.h2`
  color: #e65100;
  font-size: 16px;
`;

下記のコマンドを叩いて、ビルドが成功するかどうか確認してみましょう。通常であれば、3000番ポートでアプリが起動するはずです。

yarn dev

ブラウザのJavaScriptをオフにしてNext.jsアプリを検証

本当にサーバーサイドレンダリングがされているかどうか、クライアント側で動作するJavaScriptを一旦オフにしてから、ページにアクセスしてみます。ここではFirefoxでJavaScriptをオフにしてから、Firefoxのアドレスバーに「about:config」と入力して「Enter」キーを押します。

Next.js サーバーサイドレンダリング JavaScriptオフ Next.js Firefox JavaScript

ここで、「http://localhost:3000」や「http://localhost:3000/thai」にアクセスしてみると、ブラウザのJavaScriptがオフになっている (ブラウザ側でReactを動作させることはできない。) にもかかわらず、Reactでページがレンダリングされるので、サーバーサイドレンダリングが実行されていることが確認できます。

Next.jsサンプルイメージ