こんにちは
DROBE のエンジニアの小黒です
直近のプロジェクトで Next.js を利用する機会があったので、DROBEでどのように利用したか簡単にご紹介したいと思います
Next.js の導入を検討している方の参考になれば幸いです
- Next.js 導入の背景
- Next.js について
- Next.js とは
- DROBE での Next.js の利用事例(開発編)
- ディレクトリ構造
- styled components
- サイトマップ
- API call
- next/Image コンポーネントについて
- DROBE での Next.js の利用事例(デプロイ編)
- 1. ビルド周り
- 2. 静的ファイルの配信周り
- 3. drobe.jp 配下に置くための設定
- おわりに
Next.js 導入の背景
DROBE では、AI とスタイリストがお客様一人一人の要望や好みに合わせて商品とコーディネートを提案させていただいているのですが、
ある時、マーケティングなどで利用するために、実際に取り扱っている商品やコーディネートの例を会員のお客様以外にも公開できる仕組みを作りたい、という話があがりました
PDM からもらった要件は下記の3つがあり
- 認証などの機能なしで閲覧できること
- SEO を考慮した Web ページになること
- drobe.jp ドメインで配信できること(既存サービスと同じドメイン配下におけること)
エンジニア内で話し合ったところ、下記の観点を重視して、Next.js を使って SSG でページを生成し、静的ホスティングサービスを使ってページを公開する、という方針で開発することにしてみたのでした
- 障害時などに既存サービスへの影響が少ない
- サーバーの管理を行わなくても良い
- DROBE 内の別開発でも使っている React.js を使って作られたコンポーネントを使いまわせる
- 急な高トラフィックに強い
- ページ読み込みが早く、 SEO に強い
Next.js について
Next.js とは
vercel という hosting サービスを提供している会社が提供している React ベースのフレームワークで、色々といい感じに最適化してくれながら SSR / SSG の機能を提供してくれます
開発用の hot reload 機能付きのサーバー が付いていたり、 sass や typescript を default でビルドに使えたりと、開発環境も充実しており最高の開発体験を味わえます
deploy 先は当然 vercel が最も相性が良いので、特別な理由がなければ vercel を選びましょう(DROBE はインフラが AWS 上に構築されていたので AWS 上への deploy になっています)
この後の利用事例ではチュートリアルに掲載されている Next.js の知識はある前提でご紹介しますので、Next.js ...? という方はぜひチュートリアルを読んでから利用事例の章に進んでください
Next.js のチュートリアルは非常に素晴らしく、短時間で概念を理解できるのでオススメです
DROBE での Next.js の利用事例(開発編)
DROBE での Next.js を使った開発に関して、簡単にご紹介します
主に利用ライブラリなどについて触れます
ディレクトリ構造
そこまで複雑なプロジェクトではなかったこともあり、ディレクトリ構造は特にトリッキーなことはしていません
構造は下記のようになっています
.
├── api
├── components
│ ├── atoms
│ ├── molecules
│ ├── organisms
│ ├── pages
│ ├── quantums
│ └── templates
├── const
├── pages
│ ├── _app.tsx
│ ├── _document.tsx
│ └── xxxx.tsx
├── records
├── styles
└── util
特筆するべき点もないくらいにシンプルです
styled components
DROBE では styled components をよく用いているので、今回も用いることにしました
他のプロジェクトで用いられているコンポーネントを一部持ってくることを想定していたので導入は必須でした
styled components と Next.js では想定している動作環境が違うらしく babel.config.json を作って styled components がサーバーサイドでも動くように設定してあげる必要があるみたいです
{
"presets": [
"next/babel"
],
"plugins": [
[
"styled-components",
{
"ssr": true,
"displayName": true,
"preprocess": false
}
]
]
}
ただ、 styled components を用いたことによってページを開いたタイミングで、一瞬 css の適用がおそくなるという問題に遭遇しました
どうやら styled components 側で css を描画するタイミング が微妙に遅れてしまうことによって発生しているようでした。
いわゆる FOUC( Flash of Unstyled Content ) という事象らしいです
さすがにこれは見栄えが悪すぎるとのことで調べたところドキュメントに下記の記載がありました
ご丁寧にサンプルまで用意してくれていました
どうやらビルド時に事前に css を作成しておき、Head にスタイルを注入しておいてくれることによってこの問題を解消できるようでした
サンプルの通り _document.tsx
に下記のように記載することにしてみました
import Document from 'next/document';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
static async getInitialProps(ctx) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: App => props => sheet.collectStyles(<App {...props} />)
});
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
)
};
} finally {
sheet.seal();
}
}
}
その結果下記のように適用遅れがなくなりました
MaterialUI なども同じような問題に遭遇するため、一律この対応が必要なようです
サイトマップ
SEO を考慮した Web ページとのことで サイトマップは必須です
DROBE では sitemap の生成は next-sitemap を用いることにしました
難しい設定は一切なく、シンプルで非常に使いやすかったです
module.exports = {
siteUrl: process.env.SITE_URL,
generateRobotsTxt: true,
}
大量にページを生成する予定なので、 sitemap の分割なども検討しなくてはならないのですが、このライブラリは全部よろしくやってくれるみたいで非常に便利でした
API call
静的なページといいつつ、絶対に最新じゃなければならない情報(価格など)に関してはどうしてもサーバーサイドからとってきたい、という話になり SSG なものの、一部 API を叩くことにしました
DROBE では普段から使い慣れている axios を使って動的に情報を取ってきています
~ api/item.ts ~
export const getItem = ({ sku }) => {
return axios.get(`/items/${sku}`);
}
~ pages/item.tsx ~
export default function Page({ sku }) {
const [item, setItem] = useState(null)
useEffect(() => {
const func = async () => {
const response = await getItem({ sku })
setItem(response.data)
}
func()
}, [sku])
return <ItemPage item={item} />
}
特別変わったことはしておらず、 hook を使って api call → 取得結果を反映、という処理を page に記載しています
next/Image コンポーネントについて
next/Image コンポーネントは Next.js が提供してくれているコンポーネントの1つで、クライアントの環境に合わせて画像の表示を自動でいい感じに調整してくれるという Next.js の神機能です
画像の WebP フォーマットへの変換、Lazy Load、レスポンシブ対応などを行なってくれます
ただし、この機能を適用した状態で next export を起動すると...
絶望です。どうやらデフォルトのローダーは使えず、自分で作ったローダーを設定しなくてはならないようです
デフォルトのローダーではリクエストが来たタイミングでいい感じにサーバーで処理を行う仕組みになっているようで、next export で出力された静的なファイルではその仕組みが使えないとのことです
こういった背景から今回は泣く泣く next/Image の利用をやめました
DROBE での Next.js の利用事例(デプロイ編)
DROBE ではざっくり下記のような構成で本番環境を構築しました
もともと AWS の上で運用しているサービスが多いので、今回のプロジェクトでも AWS を使って配信しています
1. ビルド周り
github action で s3 にアップロードしたソースコードをダウンロードしてきて、CodeBuild 内で next build
を叩くようにしています
build が終わったら next export
コマンドで生成したファイルを hosting 用の s3 のバケットにアップロード ( s3 sync
) しています
商品情報を管理している DROBE の基盤システムに対して CodeBuild からリクエストを投げて、生成する対象の商品 id や商品の詳細情報、コーディネートの情報などを取得して、ページを生成しています
各種情報は不定期で更新されることがあるため、CloudWatch Event で定期的に CodeBuild を起動してビルドし、最新の情報を取得するようにしています
2. 静的ファイルの配信周り
配信は Cloudfront と s3、そして間に Lambda Edge を使っています
通常の静的ファイルの hosting であれば Cloudfront の参照先を s3 に向けてあげるだけで良いのですが、Next.js だと問題が生じてしまいます
Next.js では、デフォルトのルーティングは pages 配下のファイルをそのまま URL に置き換えたものになります
つまり、 pages/hoge.tsx
というファイルがあった場合には /hoge
でアクセスされることを想定するようになるのです
pages
├── hoge.tsx
├── [fuga].tsx
..
URL は ↓ になる
/hoge
/{fuga}
そのためプロジェクト内で、 next/link
や next/router
を用いてリンクをしようとした際には全てのリンクは /hoge
などのように拡張子を抜いた形で遷移先が設定されるようになります
しかし、 Next.js で export されたファイルは、 /hoge.html
というファイル形式で出力されますので、 /hoge
などのように拡張子を抜いた形でアクセスすることはできません
ここで活躍したのが lambda edge です
Lambda Edge を設定しておけば、 Cloudfront へのアクセス時にソースコードを実行してくれるようで、今回の例でいうとアクセス時に拡張子が付いていない場合に .html
をつけてアクセスしに行くようなコードを差し込みました
exports.handler = (event, context, callback) => {
var request = event.Records[0].cf.request
var olduri = request.uri
var newuri = (path.extname(olduri) != "" || path.basename(olduri) == "") ? olduri : olduri.replace(/$/, '.html')
request.uri = newuri
return callback(null, request)
};
これで Next.js が期待する URL で s3 上の各 HTML ファイルにアクセスすることができるようになりました
※ trailing slash オプションをつけてビルドすると、全て /hoge/index.html
のように フォルダの中に index.html
を作るようになるため、 デフォルトのルートオブジェクトを index.html
に設定すれば、 Lambda Edge なしでリンクなどが正常に動きます(ただし url の最後が /
で終わってしまうため、見栄えが良くないなあという話になりDROBEでは採用しませんでした)
このままでも該当ページにアクセスできるようになるのですが、用意していないページにアクセスすると、s3でよく見る下記のエラーに遭遇してしまいます
Cloudfront では、エラー時のレスポンスも定義できるので、少し無理やりですが、↑のページで 403 がかえった場合には ステータスコードを 404 に切り替えて、 404 用のページのコンテンツを返すようにしています
3. drobe.jp 配下に置くための設定
DROBE では ALB を使って ECS 上で動作する既存サービスへリクエストを流しているのですが、特定の path のみ 今回作った Next.js のプロジェクトに向けるように構成を変更する必要がでてきました
Cloudfront を ALB の前に置くのが一般的な構成のようだったのですが、既存サービスへの影響を極力抑えたい、という背景から リクエスト → ALB → DROBE という流れを変更しないで行う、ということを検討しました
その結果だいぶトリッキーですが、 ALB の一部の path の転送先を Lambda に設定し、HTTP Request を投げて Cloudfront のコンテンツを取得してきて返すという処理を書くことにしました
Lambda に関しては下記のような関数を設定し、 Cloudfront 側へ 内部でリクエストを投げた結果を返すようにしています
const https = require('https')
exports.handler = async (event, context) => {
return await new Promise(resolve => {
const queryString = '?' + Object.entries(event.queryStringParameters).map((e) => `${e[0]}=${e[1]}`).join('=')
const path = event.path + '' + (queryString == '?' ? '' : queryString)
https.get(`https:\/\/${process.env.CLOUDFRONT_DOMAIN}${path}`, (res) => {
let body = ''
const headers = res.headers
const statusCode = res.statusCode
res.on('data', d => body += d )
res.on('close', () => resolve({ statusCode, headers, body }))
})
})
}
ここの部分に関しては運用しながらベストプラクティスを模索していきたいと思っています
おわりに
DROBE での Next.js の利用事例をご紹介しました
ドキュメントが非常に丁寧なことと、使い方自体がとても簡単で多機能なことから、 Next.js は今後の開発においてもどんどん利用していきたいなと思えるフレームワークでした