Next.js使ってSSR(AWS Lambda) + Prerender.ioやってみた

ReactのSEO対策をやることになったので自分用にｃ⌒っﾟдﾟ)っφ　ﾒﾓﾒﾓ...

いろいろ調査した結果SSRとDynamicRenderingを組み合わせて使うのが良さそうだったので、Next.js(SSR)+Prerender.io(DynamicRendering)を使ってみた。

# Next.js
### インストール
```bash
mkdir test && cd $_
npm init -y
npm i -S next react react-dom
```

### セットアップ
```diff:package.json
  "scripts": {
-   "test": "echo \"Error: no test specified\" && exit 1"
+   "dev": "next"
  },
```

### 確認
```js:pages/index.js
export default () => <div>Hellow World</div>
```

# Typescript
### インストール
```bash
npm i -D @zeit/next-typescript typescript
npm i -D @types/react @types/react-dom @types/next
```

### セットアップ
```json:.babelrc
{
  "presets": [
    "next/babel",
    "@zeit/next-typescript/babel"
  ]
}
```
 
```js:next.config.js
const withTypescript = require("@zeit/next-typescript")
module.exports = withTypescript()
```

```json:tsconfig.json
{
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "jsx": "preserve",
    "lib": [
      "dom",
      "es2018"
    ],
    "module": "esnext",
    "moduleResolution": "node",
    "noEmit": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitAny": true,
    "noImplicitReturns": true,
    "preserveConstEnums": true,
    "removeComments": false,
    "skipLibCheck": true,
    "sourceMap": true,
    "strict": true,
    "target": "esnext"
  },
  "exclude": [
    "node_modules"
  ],
  "include": [
    "**/*.ts",
    "**/*.tsx"
  ]
}
```
`tsconfig.json`の設定はお好みに。

### 確認
```ts:pages/index.tsx
import * as React from "react"

const App: React.FC<{}> = () => <div>Hello World</div>

export default App
```

# ESLint + Prettier
TypescriptなのでTSLintも検討したが、typescript-eslintのほうが良さげだったのとprettier-eslint-cli使えると便利なのでLinterはESLintにした。

### インストール
```bash
npm i -D eslint @typescript-eslint/{eslint-plugin,parser} eslint-config-prettier eslint-plugin-prettier prettier prettier-eslint prettier-eslint-cli
```

### セットアップ
```json:.eslintrc
{
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "project": "./tsconfig.json",
    "tsconfigRootDir": "."
  },
  "plugins": ["@typescript-eslint", "prettier"],
  "extends": [
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier/@typescript-eslint"
  ],
  "rules": {
    "prettier/prettier": "error"
  }
}
```

```json:.prettierrc
{
  "semi": false,
  "trailingComma": "all",
}
```

```:.eslintignore
.next/
node_modules/
```

```:.prettierignore
.next/
node_modules/
```

# Storybook
せっかくなのでStorybookも入れてみる

### インストール
```bash
npm i -D @storybook/react  @babel/core babel-loader babel-preset-react-app @types/storybook__react @types/node fork-ts-checker-webpack-plugin
```

### セットアップ
```diff:package.json
  "scripts": {
    "dev": "next",
+   "storybook": "start-storybook -p 6006 -c .storybook"
  },
```

```js:.storybook/config.js
import { configure } from "@storybook/react"

const req = require.context("../components", true, /.stories.tsx$/)
function loadStories() {
  req.keys().forEach(filename => req(filename))
}

configure(loadStories, module)
```

```js:.storybook/webpack.config.js
const path = require("path")
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin")

module.exports = async ({ baseConfig, env, config }) => {
  config.module.rules.push({
    test: /\.(ts|tsx)$/,
    loader: require.resolve("babel-loader"),
    options: {
      presets: [require.resolve("babel-preset-react-app")],
    },
  })

  config.resolve.extensions.push(".ts", ".tsx")

  config.plugins.push(
    new ForkTsCheckerWebpackPlugin({
      async: false,
      checkSyntacticErrors: true,
      formatter: require("react-dev-utils/typescriptFormatter"),
    }),
  )
  return config
}
```

### 確認
```ts:components/Button.tsx
import * as React from 'react'

interface Props {
  text: string
  onClick(): void
}

const Button: React.FC<Props> = (props: Props) => (
  <button onClick={props.onClick}>{props.text}</button>
)

export default Button
```

```ts:components/Button.stories.tsx
import * as React from "react"
import { storiesOf } from "@storybook/react"
import Button from "./Button"

storiesOf("Button", module).add("with text", () => {
  return <Button text="Hello World" onClick={() => alert("Hello")} />
})
```

```bash
npm run storybook
```

# StyledComponents
### インストール
```bash
npm i -S styled-components
npm i -D @types/styled-components babel-plugin-styled-components
```

### セットアップ
```diff:package.json
{
  "presets": [
    "next/babel",
    "@zeit/next-typescript/babel"
- ]
+ ],
+ "plugins": [["styled-components", { "ssr": true }]]
}
```

### 確認
```ts:components/Button.tsx
import * as React from 'react'
import styled from 'styled-components'

interface Props {
  text: string
  onClick(): void
}

const StyledButton = styled.button`
  & {
    padding: 6px 16px;
    color: #fff;
    background-color: #2196f3;
    border-radius: 4px;

    &:hover {
      background-color: #1976d2;
    }
  }
`

const Button: React.FC<Props> = (props: Props) => (
  <StyledButton onClick={props.onClick}>{props.text}</StyledButton>
)

export default Button
```

# Apex/Up
今回はAWS Lambdaにデプロイしたいので、その辺を楽にしてくれるApex/Upというツールを導入してみた。
何これ超楽

### インストール
```bash
curl -sf https://up.apex.sh/install | sh
up version
```

### セットアップ
[ここ](https://up.docs.apex.sh/#aws_credentials)を参考にIAMロール、IAMユーザを作成

```~/.aws/credentials
[ProfileForApexUp]
aws_access_key_id = YOUR_ACCESS_KEY_ID
aws_secret_access_key = YOUR_SECRET_ACCESS_KEY
```

```json:up.json
{
  "name": "your name",
  "profile": "your profile name",
  "regions": [
    "ap-northeast-1"
  ],
  "lambda": {
    "memory": 256,
    "runtime": "nodejs8.10"
  }
}
```

```:.upignore
!.next
```

```diff:package.json
  "scripts": {
    "dev": "next",
+   "build": "next build",
+   "start": "next start"
  },
```

### デプロイ
```bash
up # stagingデプロイ
up production # prodデプロイ
```

# prerender.io
Expressでカスタムサーバを作成し、Next.jsのSSRの機構にprerenderを組み込む

### 登録
[ここ](https://prerender.io/)から登録し、Tokenを取得

### インストール
```bash
npm i -S express prerender-node
```

### セットアップ
> 事前にApex/UpでデプロイしたAPI Gatewayの各ステージにカスタムドメインを割り当てておく。

```js:server.js
const express = require("express")
const next = require("next")

const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== "production"
const app = next({ dev })
const handle = app.getRequestHandler()

app
  .prepare()
  .then(() => {
    const server = express()

    if (process.env.PRERENDER_TOKEN) {
      server.use(
        require("prerender-node").set(
          "prerenderToken",
          process.env.PRERENDER_TOKEN,
        ),
      )
    }

    server.get("*", (req, res) => {
      return handle(req, res)
    })

    server.listen(port, err => {
      if (err) throw err
      console.log(`> haha Ready on http://localhost:${port}`)
    })
  })
  .catch(ex => {
    console.log(ex)
    process.exit(1)
  })
```

```diff:package.json
  "scripts": {
-   "dev": "next",
+   "dev": "node server.js",
    "build": "next build",
-   "start": "next start"
+   "start": "NODE_ENV=production node server.js"
  },
```

```diff:up.json
  "environment": {
    "NODE_ENV": "production",
+   "PRERENDER_TOKEN": "Your Prerender Token Here"
  },
+ "stages": {
+   "production": {
+     "domain": "Your Domain"
+   }
+ }
```

```:.prettierignore
.next/
node_modules/
```

### デプロイ&prerender.io設定
`up production`した後、prerender.ioの設定画面に作成したページのURLをキャッシュするよう設定して終了