Next.js + TypeScript + Material-UI で静的HTMLをエクスポートするまで

# はじめに
仕事で Next.js を使っているので、開発環境を初期構築する方法をメモします。
TypeScript と Material-UI を含めた鉄板ネタ？で簡単に環境構築できます。

## 前提
パッケージマネージャーとして [yarn](https://classic.yarnpkg.com/en/) をインストールしています。

## Next.js
React をベースにしたフレームワークで、SSRやらSSGが簡単に実現できます。
その中でも私はファイルベースのルーティングが好きなので、SSRやらSSGが必要なくても Next.js を選択したいものです。
Next.js の詳細は[こちらの公式ページ](https://nextjs.org/)を参照。

## TypeScript
言わずもがな JavaScript に静的な型付けができます。

## Material-UI
私のようなデザインセンスのないエンジニアでも、簡単にカッコいいWebデザインを作れる（かもしれない）UIコンポーネントを提供してくれます。
Material-UI の詳細は[こちらの公式ページ](https://material-ui.com/)を参照。

# Next.js のプロジェクトを構築する
まず Next.js のプロジェクトを構築していきます。
React の `create-react-app` と同じように `create-next-app` で簡単に初期構築できます。
また、[サンプル](https://github.com/vercel/next.js/tree/master/examples)がたくさん用意されているので、自分の目的に合わせて近しいプロジェクトを選択して初期構築することができます。
例えば今回だと Material-UI のプロジェクトを作ってみようと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361154/4babd887-38ab-93cd-cd91-1c476b0f038d.png)
はい。ありましたね。
サンプルから初期構築する場合は `-e` オプションを使います。
`npx create-next-app -e with-material-ui my-material-ui`
てな感じです。簡単ですね。
ではやってみます。

```zsh
$ npx create-next-app --example with-material-ui my-material-ui
Could not locate an example named "with-material-ui". Please check your spelling and try again.
```

**失敗しました！**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361154/0703e9e5-122e-563f-61fe-e526818537f3.png)
サンプルは既に[こちら](https://github.com/mui-org/material-ui/tree/master/examples/nextjs)に移動されているようです。なるほど。
仕方ないので別のサンプルを使ってみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361154/1b50e6f6-71c1-900b-a71f-223e9c8847bb.png)

```zsh
$ npx create-next-app -e with-typescript-eslint-jest my-nextjs
Creating a new Next.js app in /../my-nextjs.

Downloading files for example with-typescript-eslint-jest. This might take a moment.

Installing packages. This might take a couple of minutes.


added 1099 packages, and audited 1100 packages in 36s

106 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Initialized a git repository.

Success! Created my-nextjs at /../my-nextjs
Inside that directory, you can run several commands:

  npm run dev
    Starts the development server.

  npm run build
    Builds the app for production.

  npm start
    Runs the built app in production mode.

We suggest that you begin by typing:

  cd my-nextjs
  npm run dev
```

**今度は成功しました！**
`yarn run dev` 後に `localhost:3000` にアクセスして以下のページが表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361154/418c0e37-31a4-d748-bec0-7769426d975c.png)


# Material-UI を追加する
[ここ](https://material-ui.com/ja/getting-started/installation/#npm)にある通り、手動で追加することにします。

```zsh
$ yarn add @material-ui/core
yarn add v1.22.10
info No lockfile found.
warning package-lock.json found. Your project contains lock files generated by tools other than Yarn. It is advised not to mix package managers in order to avoid resolution inconsistencies caused by unsynchronized lock files. To clear this warning, remove package-lock.json.
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
success Saved 223 new dependencies.
・・・
```

`pages/index.tsx` を以下のようにして Material-UI を適用してみます。

```react:pages/index.tsx
import Head from 'next/head'
import Image from 'next/image'
import AppBar from '@material-ui/core/AppBar'
import Toolbar from '@material-ui/core/Toolbar'
import Typography from '@material-ui/core/Typography'
import Container from '@material-ui/core/Container'
import { makeStyles } from '@material-ui/core/styles'
import CssBaseline from '@material-ui/core/CssBaseline'

const useStyles = makeStyles((theme) => ({
  root: {
    display: 'flex',
    flexDirection: 'column',
    minHeight: '100vh',
  },
  main: {
    marginTop: theme.spacing(8),
    marginBottom: theme.spacing(2),
  },
  footer: {
    padding: theme.spacing(3, 2),
    marginTop: 'auto',
    backgroundColor: theme.palette.primary.main,
    color: 'white',
    textAlign: 'center',
  },
}))

export const Home = (): JSX.Element => {
  const classes = useStyles()
  return (
    <div className={classes.root}>
      <CssBaseline />
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <header>
        <AppBar position="relative">
          <Toolbar>
            <Typography variant="h6" color="inherit" noWrap>
              {"Material-UI"}
            </Typography>
          </Toolbar>
        </AppBar>
      </header>

      <main className={classes.root}>
        <Container component="main" className={classes.main} maxWidth="md">
          <h1 className="title">
            Welcome to <a href="https://nextjs.org">Next.js!</a>
          </h1>

          <p className="description">
            Get started by editing <code>pages/index.tsx</code>
          </p>

          <button
            onClick={() => {
              window.alert('With typescript and Jest')
            }}
          >
            Test Button
          </button>

          <div className="grid">
            <a href="https://nextjs.org/docs" className="card">
              <h3>Documentation &rarr;</h3>
              <p>Find in-depth information about Next.js features and API.</p>
            </a>

            <a href="https://nextjs.org/learn" className="card">
              <h3>Learn &rarr;</h3>
              <p>Learn about Next.js in an interactive course with quizzes!</p>
            </a>

            <a
              href="https://github.com/vercel/next.js/tree/master/examples"
              className="card"
            >
              <h3>Examples &rarr;</h3>
              <p>Discover and deploy boilerplate example Next.js projects.</p>
            </a>

            <a
              href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
              className="card"
            >
              <h3>Deploy &rarr;</h3>
              <p>Instantly deploy your Next.js site to a public URL with Vercel.</p>
            </a>
          </div>
        </Container>
      </main>

      <footer className={classes.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by{' '}
          <Image src="/vercel.svg" alt="Vercel Logo" height={'32'} width={'64'} />
        </a>
      </footer>

      <style jsx>{`
        // 変更なしのため割愛
        ・・・
      `}</style>
    </div>
  )
}

export default Home
```
- 補足
  - `<style>` の中身は初期状態から変更なし。長いので省略。
  - 長くなってしまったので、本来であればコンポーネントを分割すべきでしょうね。

画面はこんな感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361154/ad6d5f91-acd4-9154-84c1-27ac0eb05b98.png)
ただ、コードを修正してページ更新してみるとブラウザのコンソールにこんなエラーが。。。

```console
Warning: Prop `className` did not match. Server: "MuiContainer-root makeStyles-main-5 MuiContainer-maxWidthMd" Client: "MuiContainer-root makeStyles-main-2 MuiContainer-maxWidthMd"
    at main
    at Container (webpack-internal:///./node_modules/@material-ui/core/esm/Container/Container.js:85:23)
    at WithStyles (webpack-internal:///./node_modules/@material-ui/styles/esm/withStyles/withStyles.js:61:31)
    at main
    at div
    at Home (webpack-internal:///./pages/index.tsx:58:17)
    at App (webpack-internal:///./node_modules/next/dist/pages/_app.js:77:5)
    at ErrorBoundary (webpack-internal:///./node_modules/@next/react-dev-overlay/lib/internal/ErrorBoundary.js:23:47)
    at ReactDevOverlay (webpack-internal:///./node_modules/@next/react-dev-overlay/lib/internal/ReactDevOverlay.js:73:23)
    at Container (webpack-internal:///./node_modules/next/dist/client/index.js:155:5)
    at AppContainer (webpack-internal:///./node_modules/next/dist/client/index.js:643:24)
    at Root (webpack-internal:///./node_modules/next/dist/client/index.js:779:25)
```
そこで先程紹介した[移動したサンプル](https://github.com/mui-org/material-ui/tree/master/examples/nextjs)を参考に、以下のファイルを追加します。

- 追加したファイル
    - pages/_app.tsx
    - pages/_document.tsx

```react:pages/_app.tsx
import React from 'react'
import Head from 'next/head'
import { AppProps } from 'next/app'
import CssBaseline from '@material-ui/core/CssBaseline'

const App: React.FC<AppProps> = ({ Component, pageProps }) => {
  React.useEffect(() => {
    // Remove the server-side injected CSS.
    const jssStyles = document.querySelector('#jss-server-side')
    if (jssStyles && jssStyles.parentElement) {
      jssStyles.parentElement.removeChild(jssStyles)
    }
  }, [])

  return (
    <React.Fragment>
      <CssBaseline />
      <Head>
        <title>Create Next App</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <Component {...pageProps} />
    </React.Fragment>
  )
}

export default App
```
- _app.tsx の補足
    - 全ページ共通の処理を実装できますが、今回のエラーとは特に関係なさそう。
    - だけどあると便利なので、pages/index.tsx で実装した `<CssBaseLine />` と `<Head>` はこちらに移動してしまう。

```react:pages/_document.tsx
import { ServerStyleSheets } from '@material-ui/core/styles'
import Document, { Head, Html, Main, NextScript } from 'next/document'
import React from 'react'

export default class MyDocument extends Document {
  render(): JSX.Element {
    return (
      <Html lang="ja-JP">
        <Head>
          {/* PWA primary color */}
          <link
            rel="stylesheet"
            href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
          />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

// `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with server-side generation (SSG).
MyDocument.getInitialProps = async (ctx) => {
  // Resolution order
  //
  // On the server:
  // 1. app.getInitialProps
  // 2. page.getInitialProps
  // 3. document.getInitialProps
  // 4. app.render
  // 5. page.render
  // 6. document.render
  //
  // On the server with error:
  // 1. document.getInitialProps
  // 2. app.render
  // 3. page.render
  // 4. document.render
  //
  // On the client
  // 1. app.getInitialProps
  // 2. page.getInitialProps
  // 3. app.render
  // 4. page.render

  // Render app and page and get the context of the page with collected side effects.
  const sheets = new ServerStyleSheets()
  const originalRenderPage = ctx.renderPage

  ctx.renderPage = () =>
    originalRenderPage({
      enhanceApp: (App) => (props) => sheets.collect(<App {...props} />),
    })

  const initialProps = await Document.getInitialProps(ctx)

  return {
    ...initialProps,
    // Styles fragment is rendered after the app and page rendering finish.
    styles: [
      ...React.Children.toArray(initialProps.styles),
      sheets.getStyleElement(),
    ],
  }
}
```
- _document.tsx の補足
    - エラー解消にはこちらのファイルの `MyDocument.getInitialProps` の定義のところが重要です。
    - 余談ですがクラスコンポーネントになってますね。こちらのファイルはサンプルをほぼ丸コピしてます。

**これでコンソールにエラーは出なくなりました！**

# 静的HTMLを出力する
Next.js には `next export` というコマンドが用意されており、簡単に静的なHTMLを出力できます。
そこで `package.json` を以下のように修正して、ビルド時に静的HTMLを出力するようにします。

```diff
diff --git a/package.json b/package.json
index 64f8a47..d2ecd0d 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,7 @@
   "version": "1.0.0",
   "scripts": {
     "dev": "next dev",
-    "build": "next build",
+    "build": "next build && next export",
     "start": "next start",
     "type-check": "tsc --pretty --noEmit",
     "format": "prettier --write .",
@@ -26,6 +26,7 @@
     ]
   },
   "dependencies": {
     "@material-ui/core": "^4.11.4",
     "next": "latest",
     "react": "^17.0.1",
     "react-dom": "^17.0.1"
```
`yarn run build` を実行します。

```console
$ yarn run build
yarn run v1.22.10
$ next build && next export
info  - Using webpack 5. Reason: no next.config.js https://nextjs.org/docs/messages/webpack5
info  - Checking validity of types  
info  - Using external babel configuration from /../my-nextjs/.babelrc
info  - Creating an optimized production build  
info  - Compiled successfully
info  - Collecting page data  
info  - Generating static pages (3/3)
info  - Finalizing page optimization  

Page                             Size     First Load JS
┌ ○ /                            10.3 kB        97.2 kB
├   /_app                        0 B            86.9 kB
├ ○ /404                         3.71 kB        90.6 kB
└ λ /api/hello                   0 B            86.9 kB
+ First Load JS shared by all    86.9 kB
  ├ chunks/205.fc6391.js         7.94 kB
  ├ chunks/433.83deda.js         12.6 kB
  ├ chunks/894.a1bad4.js         22 kB
  ├ chunks/framework.aa6452.js   42.4 kB
  ├ chunks/main.5aaba5.js        154 B
  ├ chunks/pages/_app.f0c4dc.js  801 B
  └ chunks/webpack.231c1c.js     993 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
   (ISR)     incremental static regeneration (uses revalidate in getStaticProps)

info  - Using webpack 5. Reason: no next.config.js https://nextjs.org/docs/messages/webpack5
info  - using build directory: /../my-nextjs/.next
info  - Copying "static build" directory
info  - No "exportPathMap" found in "next.config.js". Generating map from "./pages"
Error: Image Optimization using Next.js' default loader is not compatible with `next export`.
  Possible solutions:
    - Use `next start` to run a server, which includes the Image Optimization API.
    - Use any provider which supports Image Optimization (like Vercel).
    - Configure a third-party loader in `next.config.js`.
    - Use the `loader` prop for `next/image`.
  Read more: https://nextjs.org/docs/messages/export-image-api
    at /../my-nextjs/node_modules/next/dist/export/index.js:14:785
    at async Span.traceAsyncFn (/../my-nextjs/node_modules/next/dist/telemetry/trace/trace.js:6:584)
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
```
**はい。失敗します！**
どうやら原因はここにあるようです。

```react:pages/index.tsx
import Image from 'next/image'
・・・
<Image src="/vercel.svg" alt="Vercel Logo" height={'32'} width={'64'} />
```
[こんなIssue](https://github.com/vercel/next.js/issues/18356)がありますね。
どうやら`next/image`は、画像の表示をサーバー側で最適化してくれるコンポーネントっぽいです。
今回は特に不要なので React の `<img>` に置き換えました。

```react:pages/index.tsx
<img src="/vercel.svg" alt="Vercel Logo" height={'32'} width={'64'} />
```
再び `yarn run build` を実行します。

```console
$ yarn run build                  
yarn run v1.22.10
$ next build && next export
info  - Using webpack 5. Reason: no next.config.js https://nextjs.org/docs/messages/webpack5
info  - Checking validity of types  
info  - Using external babel configuration from /../my-nextjs/.babelrc
info  - Creating an optimized production build  
info  - Compiled successfully
info  - Collecting page data  
info  - Generating static pages (3/3)
info  - Finalizing page optimization  

Page                             Size     First Load JS
┌ ○ /                            7.35 kB        93.8 kB
├   /_app                        0 B            86.5 kB
├ ○ /404                         3.71 kB        90.2 kB
└ λ /api/hello                   0 B            86.5 kB
+ First Load JS shared by all    86.5 kB
  ├ chunks/262.b5f9f9.js         21.7 kB
  ├ chunks/284.ad3787.js         7.54 kB
  ├ chunks/433.d1602d.js         12.9 kB
  ├ chunks/framework.aa6452.js   42.4 kB
  ├ chunks/main.b89501.js        154 B
  ├ chunks/pages/_app.c8ce47.js  802 B
  └ chunks/webpack.1bcf44.js     993 B

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)
   (ISR)     incremental static regeneration (uses revalidate in getStaticProps)

info  - Using webpack 5. Reason: no next.config.js https://nextjs.org/docs/messages/webpack5
info  - using build directory: /../my-nextjs/.next
info  - Copying "static build" directory
info  - No "exportPathMap" found in "next.config.js". Generating map from "./pages"
info  - Launching 3 workers
warn  - Statically exporting a Next.js application via `next export` disables API routes.
This command is meant for static-only hosts, and is not necessary to make your application static.
Pages in your application without server-side data dependencies will be automatically statically exported by `next build`, including pages powered by `getStaticProps`.
Learn more: https://nextjs.org/docs/messages/api-routes-static-export
info  - Copying "public" directory
info  - Exporting (2/2)
Export successful. Files written to /../my-nextjs/out
✨  Done in 15.24s.
```
**今度は成功しました！**
念の為動作を確認します。

```zsh
$ cd out 
$ npx http-server
Starting up http-server, serving ./
Available on:
  http://127.0.0.1:8080
  http://192.168.100.49:8080
Hit CTRL-C to stop the server
```
`localhost:8080` にアクセスして以下のページが表示されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/361154/0dbb395e-619d-c406-d555-5ee0739551a6.png)
あとは S3 やら Firebase Hosting やら適当にデプロイしてくだされ。

# まとめ

- Next.js で Material-UI を使いたい場合は `create-next-app` ではなく、[このページのサンプル](https://github.com/mui-org/material-ui/tree/master/examples/nextjs)をクローンした方が早そう。
- `create-next-app` で初期構築したプロジェクトに後から `Material-UI` を入れる場合は[このページのサンプル](https://github.com/mui-org/material-ui/tree/master/examples/nextjs)から  `_app.tsx`、`_document.tsx` を忘れずにパクる。
- 静的HTMLを出力する場合は `next/image` コンポーネントのご利用は慎重に。

# ちなみに
`pages` ディレクトリは `src` 配下に移動してもルーティングとして認識してくれます。<br>私は `src` 配下に `pages` がある方が好きだな。

# 参考にしたサイト
- [Next.js & material UIで[Prop className did not match]が発生する](https://qiita.com/sobameshi0901/items/a3a95b88770a52f1f31c)