
Next.jsのプロジェクト(コード)を2つに分割して苦労した話
※この記事は自分が所属する組織で書いた以下の記事のコピーです。投稿した記事は個人の著作物として自ブログにコピーして良いルールとしています。


こんにちは、ミツカリCTOの塚本こと、つかびー(@tsukaby0) です。
ミツカリでは創業した2015年からRuby on Railsを使ってきましたが、近年ではRailsはRestful API(Backend)として位置づけており、FrontendにはNext.jsを利用しています。
創業時から2022年頃まではサービスサイト(LP)も利用者向けのWebアプリも同一のRailsアプリケーションとして構築してきました。FrontendにはERB, Sass, jQueryなどを使っていましたが、その後FrontendにNext.jsを採用して乗り換えたという感じです。
ただ、サービスサイトとWebアプリが同じコードベースである(Next.jsアプリケーションである)という点はそのままでしたが、今回そこを分離して2つのNext.jsプロジェクトに分けました。
分離は私が採用やマネジメントの傍らでやったのですが、それなりに苦戦して結局2週間ほどかかってしまいました。今回はそのあたりの分離において苦労した話(特にNext.jsのroutingの話)をしたいと思います。
Next.jsプロジェクト分離のモチベーション
サービスサイトとWebアプリが同一のNext.jsプロジェクトである場合、以下のようなメリットがあります。
- CIなどを別途整備する必要がない
- 一部のコードをサービスサイトとWebアプリで簡単に共用できる
- local環境で起動する場合プロセスが1つで済む(portが1つで済む)
- Routingについて考慮が必要ない、あるいは考慮事項が大幅に減る
- プロセスが1つであるため、インフラリソースとしては効率的、低コストにできる。APM等のコストも余計にかからない
デメリットも存在します。
- JSのbundle sizeが増える (※一部はDynamicにできるため問題にならない)
- ビルドの時間が長くなる
- サービスサイトは変更していないが、Webアプリ側は変更したというようなケースで不要な部分まで
next buildしなければならない - Dockerfileなどのアーティファクトのサイズが大きくなる
同一プロジェクト方式も悪くはないのですが、サービスサイトのパフォーマンスをより向上させたい、bundle sizeを減らしたい、ビルド時間を短縮したいなどの理由からプロジェクトを分離することにしました。
分離作業の問題点
Next.jsプロジェクトを新たに作成して、必要なコードを移したり、CIの設定を行う作業は量はあれどそれほど難しいものではありません。基本的にはコードのコピペと多少の変更で解決できます。ただし、以下についてはそれなりに苦戦しました。
staticリソースのRouting
結局ほとんどの問題はRoutingについてです。まず、現状のミツカリの問題としてサービスサイトとWebアプリケーションのドメイン・ディレクトリが明確に分かれていません。これが全ての元凶であり、この問題を抱えていない人はおそらく分離は楽ですが、弊社と同じ状況にある場合はそれなりに苦労すると思います。
より具体的には、例えば、ミツカリは https://mitsucari.com というドメインで配信されており、 https://mitsucari.com/ や https://mitsucari.com/case_studies にアクセスした場合はサービスサイト側へのアクセスとなります。 https://mitsucari.com/enterprise/users というURLはWebアプリ側へのアクセスとなります。
これらは同一のドメインであり、明確にサブディレクトリで分かれているわけではありません。Webアプリ側は /enterprise 以外で始まるURLも持っています。
Next.jsでは一部のStaticなリソースは /_next/ というURLから配信されます。以下は一例です。
/_next/static/chunks/pages/_app-110b697fc59a55ad.js
ミツカリではAWSを使っており、CDN(CloudFront)のoriginにALBを指定しています。そのALB配下ではECS FargateでDocker containerを動かしており、Next.jsアプリはstandaloneモードとしてDocker Imageにしています。今回のプロジェクト分離によってALBの配下にはNext.js(Webアプリ)のコンテナとNext.js(サービスサイト)のコンテナが存在することになります。(※正確にはコンテナというよりはターゲットグループ)
この状態で /_next/* というアクセスがALBに来た場合に困ったことが発生します。2つのコンテナ、どちらにRoutingしたらよいか分かりません。片方のコンテナだけにRoutingする設定はできます。その場合、サービスサイトは問題なく動くが、Webアプリ側は正しくRoutingされないため、動かないということになります。(取得したいリソースが存在せず404になる)
この点については以下のassetPrefixで解決できます。
この設定で適当に assetPrefix: "lp" などとすれば以下のようなURLになります。
/lp/_next/static/chunks/pages/_app-110b697fc59a55ad.js
こちらのrewriteの設定も入れましょう。
これで明確にパスが分かれたので /lp/_next/* と /_next/* でALBのRoutingを正しく設定できますね。
next/imageのRouting
Next.jsにはnext/imageという画像最適化の仕組みがあります。
このコンポーネントを使うと、画像のリサイズやクオリティの変更など、画像に関する最適化ができます。
具体的にどういう仕組かというと、このコンポーネントを使う場合、 /_next/image という画像最適化用エンドポイントが自動で作られ、そこに対して自身が指定した画像が与えられ、最適化した画像を返します。それがimgタグのsrcとして使われるわけですね。
例えばミツカリでは
/_next/image?url=%2F_next%2Fstatic%2Fmedia%2Flogo_text_black.dac18f7b.png&w=2048&q=75
というようなURLがありますが、これがnext/imageのエンドポイントでありurlパラメタがNext.jsで配信しているstaticリソースです。
残念ながらこれにも前述と同じ問題がありますが、こちらはさらに深刻です・・・。
まずassetPrefixの設定はこの /_next/image エンドポイント自体には効果がありませんし、私が調べた限りではこのURLを変更する術がありません。つまりRoutingをうまく設定することができません。
これに対しては苦肉の策として、片方のエンドポイントだけを利用することにしました。つまりWebアプリのNext.jsのエンドポイントをサービスサイト側からも使うというようなことです。あまり良いものではないので本当に苦肉の策です。
そして、それだけではまだ問題があります。
前述のURLの通りurlパラメタは相対パスになっています。 url=%2F_next%2Fstatic%2Fmedia%2Flogo_text_black.dac18f7b.png の部分に注目してください。URLエンコードされていて見づらいですが、相対パスです。つまりサービスサイトNext.jsからWebアプリのimageエンドポイントを呼び出した場合、その相対パスのファイルはWebアプリNext.jsには存在しないため、HTTP 400になってしまいます(urlパラメタが不正だというエラーになる)。
これに対しても苦肉の策ですが、サービスサイト側のnext/imageを拡張して、絶対パスを利用するように変更しました。具体的には以下のようなCustomImageを用意して差し替えています。
import React from 'react'
import Image, { ImageProps } from 'next/image'
const urlPrefix = 'あなたの絶対パス、URL'
type CustomImageProps = ImageProps & {
// 必要に応じて追加のプロパティを今後定義する
}
export const CustomImage: React.FC<CustomImageProps> = (props) => {
// STORYBOOK_CUSTOM_IMAGE_DISABLEDがtrueの場合(Storybookの場合)は、絶対パス変換は不要のため、停止
// 絶対パス変換が動いてしまうと、Storybook port 6006でlocalhost:3002(※これは設定による)の画像をロードしに行くので失敗する
// 相対パスで見る必要があるため無効化する
if (process.env.STORYBOOK_CUSTOM_IMAGE_DISABLED === 'true') {
return <Image {...props} />
}
const { src = props.src, ...rest } = props
// srcがStaticImportの場合は文字列化して、一部の文字列を先頭につけて絶対パスを作る
let extractedSrc: string
if (typeof src === 'string') {
extractedSrc = src
} else {
// srcがStaticImportの場合
// StaticImport は StaticRequire | StaticImageData のUnion型。
// StaticReequireはdefaultプロパティの中にsrcが入っており、StaticImageDataは直接srcを持っている。
// // これらの違いがあるので以下の通り型ガードで場合分けして正しくプロパティアクセスする
if ('default' in src) {
// StaticRequire型の場合
extractedSrc = src.default.src
} else {
// StaticImageData型の場合
extractedSrc = src.src
}
}
let absoluteSrc: string
if (extractedSrc.startsWith('http')) {
// 絶対パスがsrcに入るケースもあるので、その場合はprefixを付けない
absoluteSrc = extractedSrc
} else {
absoluteSrc = `${urlPrefix}${extractedSrc}`
}
return <Image src={absoluteSrc} {...rest} />
}
苦笑いしてしまいそうなワークアラウンドですね。既存の <Image> コンポーネントを全て <CustomImage> コンポーネントに置き換えることでurlパラメタに与える画像を絶対パス(URL)にすることができます。
これだけだとまだ不足しており、Webアプリ側のNext.jsがurlパラメタに対して、そのドメインは受け付けていないという理由で400を返してしまいます。これは設定の問題なので、next.config.jsに以下の設定をいれることで許可できます。
images: {
remotePatterns: [
{
protocol: "https",
hostname: "自分のドメイン",
port: "",
pathname: "/**",
},
こうすることでWebアプリ側のNext.jsのnext/imageエンドポイントに絶対パスのurlパラメタが与えられても許可されたドメインなので正常に動くし、urlパラメタの値が絶対パス(URL)なので、正しくリソースを取りに行けるというロジックです。
dataファイルのRouting問題
泣きたくなりますね。まだ問題はあります。プロジェクト分離は私の見積もりでは3日程度で終わる想定だったのですが、これらの罠のせいでかなり時間を要しています。
ここまでで、基本的なRoutingと画像の問題は解決できたので、一見すると正しく描画できますが、Browserのdebug toolで確認するとまだ一部のデータがエラー(404)になっています。
以下は一例です。
/_next/data/Jmm_3EoGtxWhxRUxJUZk1/case_studies.json
/_next/data については以下が参考になります。
マナリンク meijinさんの記事ではNext.jsのLinkが内部的にどのようなことを行っているのか解説されています。このおかげで高速にページ遷移できているわけですね。

mr_ozinさんのこちらの記事も参考になります。

Static generateするとpropsの情報はこのjsonに格納されるから、それをDLすれば後はSPA的挙動ができる、だから高速、というわけですね。
そしてまた残念ながらこの /_next/data のパスを変更する術は私が調べた限りはありません。
再びこのIssueを掲載しますが、今回私が遭遇しているような問題は他の開発者も遭遇しており、このようなIssueになっています。2投稿目でVercelのmemberが必要ないと言っていて :-1: を180個も付けられていますね。私はこの機能は必要だと思っていますが、実装はされていないようです。
このdataファイルのrouting問題については、おそらく対応せずともアプリは動きます。ただしSPAのようなページ遷移にはならず、 <Link> を使っていたとしてもMPAのようにページのドキュメントのGETから走ってしまいます。動きはするがパフォーマンスやUXの観点からは微妙と言えそうです。
これに対する対応策としてはこれもまた微妙なワークアラウンドですが、ALB側のroutingで解決することとしました。例えば以下はTerraformの設定の一部です。
resource "aws_lb_listener_rule" "frontend-lp1" {
listener_arn = aws_lb_listener.https.arn
priority = 71
action {
type = "forward"
target_group_arn = aws_lb_target_group.frontend-lp.arn
}
condition {
path_pattern {
values = [
"/case_studies",
"/_next/data/*/case_studies.json",
"/case_studies/*",
"/_next/data/*/case_studies/*.json",
]
}
}
}
AWSの公式docからは見つけられませんでしが、ELBのパスパターンでは前方一致以外でもワイルドカードが使えます。これによって無理やり対象の _next/data jsonファイルをroutingしているわけですね。
このやり方は完璧ではなく、Webアプリ側のNext.jsとサービスサイト側のNext.jsで似たようなパスがある場合に問題になる可能性がありそうです。
今回の経験から言えること
弊社のサービスサイトは10年近い歴史があり、これまで大きく整理を行ってこなかったので、その状況が今の状況を招いているとも言えそうです。
まだ製品やサービスサイトを構築中であったり、後戻りできる状況ならば以下を検討すると良さそうです。
- サブディレクトリの設計を十分に行う。例えばWebアプリ側のすべてのURLは必ず
/app/以下に配置する、サービスサイトは必ず/lp/以下に配置する、など - サブドメインを検討する。Webアプリ側の画面がほとんど認証付きでクローラーに公開する必要がない場合、アプリ側は別にサブドメインでも良い可能性がある。(SEOに詳しくないが、)Webアプリ側もサービスサイト側も同一ドメインとしてドメインの価値を高めたい場合などはサブドメインは使えない(別サイトとして認識されるため)。状況によってはサブドメインを検討する
なお、URL設計がきれいであり、サブディレクトリを使う場合、今回のような問題は簡単に回避できます。以下のようにbasePathを変更する機能が提供されているため、基本的にこれを使えばよいためです。
サブディレクトリを使った構築については以下のzaruさんの記事が参考になります。

ミツカリの場合はパターンBであり、かつ複数Next.jsですね。なかなかハードでした。
今回は色々とワークアラウンドが出現しており個人的には納得がいっていない部分も多いです。今後じわじわと理想のURL設計に近づけていき、サブディレクトリまたはサブドメインに綺麗にまとめたいなと思いました。
現在、ミツカリではITエンジニアを募集しています。興味のある方はぜひお気軽にご連絡ください!