たくさんの自由帳

Next.js 15 に移行した

投稿日 : | 0 日前

文字数(だいたい) : 7295

どうもこんばんわ。
特に書くことがないのでお正月にWSL 2で遊んでた時の画像でもおいておきます。いつかWSL 2の記事も書きたい!

Imgur

本題

Next.js 15をやろうとして気付いたらあけおめ。React 19RCが外れたのでいい加減やります。ついでに修正とかもしたいけど今は移行だけします。
見た感じそんな複雑じゃ無さそうなので、gitでいつでも戻せることを確認した上で、codemodを叩いてみる。

npx @next/codemod@canary upgrade latest

Yesyで。

Need to install the following packages:
@next/codemod@15.1.1-canary.25
Ok to proceed? (y) y

お、Turbopackが選べる?
でもこのNext.js 製ブログwebpackに依存してたような、、、

? Enable Turbopack for next dev? » (Y/n)

複数の修正をやってくれるそう。矢印上下キーで移動、スペースでチェックのON/OFFA キーですべて選択、Enter キーcodemod実行。

Imgur

React 19側のcodemodもやるか?。やるか。

? Would you like to run the React 19 upgrade codemod? » (Y/n)
? Would you like to run the React 19 Types upgrade codemod? » (Y/n)

進めてるとデプロイ先にVercelを選んでいるか聞かれた。
静的サイトかつAWSなのでnで。

? Is your app deployed to Vercel? (Required to apply the selected codemod) » (Y/n)

なんか入れないといけないらしい。いれるか。
yでエンター。

Need to install the following packages:
types-react-codemod@3.5.2
Ok to proceed? (y)

終わった。

✔ Codemods have been applied successfully.

Please review the local changes and read the Next.js 15 migration guide to complete the migration.
https://nextjs.org/docs/canary/app/building-your-application/upgrading/version-15

差分

page.tsxroute.tsx

Imgur

package.json

codemodを叩いた際にTurbopack有効を選んだので、開発時はTurbopackが有効になるオプションが指定されています。

  "scripts": {
    "dev": "next dev --turbopack",

あと一番下になにか追加されてる。

"overrides": {
"@types/react": "19.0.2"
}

props が Promise 経由で渡される

ページのURL パス(動的ルーティングのあれ)とかを関数の引数にPropsとしてつけていましたが、このPropsPromise<Props>のように、非同期でURLのパラメータとかが渡ってくるようになりました。

手動で直す場合は、まず各ページの引数の型をPromise<>でかこって、

async functionpage.tsxを書いている場合はawait propsすれば良さそう(codemodがそうしてた)

Upgrading: Version 15 | Next.js

Upgrade your Next.js Application from Version 14 to 15.

functionpage.tsxしている場合は、PromiseReactから読み取るReact 19の新機能、use()Hookを使えば良いって書いてあります。

Upgrading: Version 15 | Next.js

Upgrade your Next.js Application from Version 14 to 15.

以下async functionpage.tsxを直した際の差分。

diff --git a/app/posts/[blog]/page.tsx b/app/posts/[blog]/page.tsx
index 228d44e..d6cc1a7 100644
--- a/app/posts/[blog]/page.tsx
+++ b/app/posts/[blog]/page.tsx
@@ -14,11 +14,12 @@ import "../../../styles/css/content.css"

 /** 動的ルーティング */
 type PageProps = {
-    params: { blog: string }
+    params: Promise<{ blog: string }>
 }

 /** head に値を入れる */
-export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
+export async function generateMetadata(props: PageProps): Promise<Metadata> {
+    const params = await props.params;
     const markdownData = await ContentFolderManager.getBlogItem(params.blog)
     const ogpTitle = `${markdownData.title} - ${EnvironmentTool.SITE_NAME}`
     const ogpUrl = `${EnvironmentTool.BASE_URL}${markdownData.link}`
@@ -41,7 +42,8 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
  * 記事本文。
  * 反映されない場合はスーパーリロードしてみてください。
  */
-export default async function BlogDetailPage({ params }: PageProps) {
+export default async function BlogDetailPage(props: PageProps) {
+    const params = await props.params;
     // サーバー側でロードする
     const markdownData = await ContentFolderManager.getBlogItem(params.blog)

Route Handlers (API Routes)route.ts (.tsx)に関しても同様にPromisePropsを渡してくれる設計だそうです。
このサイトではOGP画像の生成で使ってて、静的書き出し時にGETリクエストされOGP画像も静的書き出しされます。

diff --git a/app/posts/[blog]/opengraph-image.png/route.tsx b/app/posts/[blog]/opengraph-image.png/route.tsx
index af60d1a..488e623 100644
--- a/app/posts/[blog]/opengraph-image.png/route.tsx
+++ b/app/posts/[blog]/opengraph-image.png/route.tsx
@@ -7,7 +7,7 @@ import FileReadTool from "../../../../src/FileReadTool"

 /** 動的ルーティング */
 type PageProps = {
-    params: { blog: string }
+    params: Promise<{ blog: string }>
 }
 
 /**
@@ -17,7 +17,8 @@ type PageProps = {
  * 使える CSS は以下参照:
  * https://github.com/vercel/satori
  */
-export async function GET(_: Request, { params }: PageProps) {
+export async function GET(_: Request, props: PageProps) {
+    const params = await props.params;
     // 記事を取得
     const markdownData = await ContentFolderManager.getBlogItem(params.blog)

私の環境では以上で、codemodがすべて直してくれました。

実行できない

webpackに依存してたような、、、それでTurbopackにしたから動かないのかも。

React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: object.
React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: object.
 ⨯ [Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object.] {
  digest: '356132257'
}

Imgur

SVGR で webpack を使っていたので修正する

SVGRSVGのファイルをReactのコンポーネントとして表示できるやつで、
import Icon from 'icon.svg'みたいにsvgimportするだけで、Reactのコンポーネントとして<Icon />みたいに使えるあれ。便利。

Turbopackwebpack使えないものだと思ってたんですが、Turbopackでも@svgr/webpackは動くって言ってる。ん?
でも手元で動いてないんだよな

next.config.js: turbopack | Next.js

Configure Next.js with Turbopack-specific options

どうやらこの通りに書くと動くらしい。やってみる

next.config.js

/** @type {import('next').NextConfig} */
module.exports = {
    // この webpack と
    webpack(config) {
        // SVG をコンポーネントにできる
        config.module.rules.push({
            test: /\.svg$/,
            use: ['@svgr/webpack'],
        })
        return config
    },
    // これを追加
    experimental: {
        turbo: {
            rules: {
                '*.svg': {
                    loaders: ['@svgr/webpack'],
                    as: '*.js',
                },
            },
        }
    }
}

開発モードで動いた

Turbopack、速い。。と思う。私の作りが悪い可能性も十分あるけど。

Imgur

静的書き出し出来ない

このサイトはNext.js output: 'export'で動いています。
書き出した成果物をS3にあげCloudFrontでお届けしています。

サイトマップ

npm run buildしたら次のエラーが

 ✓ Linting and checking validity of types    
   Collecting page data  ..Error: export const dynamic = "force-static"/export const revalidate not configured on route "/sitemap.xml" with "output: export". See more info here: https://nextjs.org/docs/advanced-features/static-html-export
    at <unknown> (C:\Users\takusan23\Desktop\Dev\NextJS\ziyuutyou-next\.next\server\app\sitemap.xml\route.js:1:1127)

sitemap.tsを静的書き出ししようとしたときのエラー。
export const dynamicexport const revalidateが無いから怒られてる?

export const dynamic = "force-static"とかは、Next.js自体をサーバーで動かす場合(Vercelレンタルサーバー、その他)に使うやつで、
あらかじめ生成する静的書き出しモードだと別に指定しなくて良いんじゃないの?って思ってたけど明示的にforce-staticって書かないとだめなのかな。
静的書き出しだとビルドコマンドを叩いた時点ですべてのHTMLが生成されるので、書かずとも全部force-staticになるけど。


// 静的書き出しなので指定する必要がないはずだが、Next.js 15 から無いとエラーになってしまう
// https://github.com/vercel/next.js/issues/68667
export const dynamic = "force-static"

/**
 * サイトマップを生成する。Next.js 単体で作れるようになった。
 * Trailing Slash が有効なので最後にスラッシュ入れました。
 */
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
    // 以下省略...
}

OGP 画像に SVG を描画したらエラー

次はこれ。

Image data URI resolved without size:data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"> ... </svg>
Image data URI resolved without size:data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"> ... </svg>
Error occurred prerendering page "/posts/amairo_kotlin_coroutines_flow/opengraph-image.png". Read more: https://nextjs.org/docs/messages/prerender-error
Error: SVG data parsing failed cause invalid attribute at 1:44192 cause expected '"' not '<' at 1:44219
    at imports.wbg.__wbg_new_15d3966e9981a196 (file:///C:/Users/takusan23/Desktop/Dev/NextJS/ziyuutyou-next/node_modules/next/dist/compiled/@vercel/og/index.node.js:18406:17)

ちなみにこれは開発時でも同じエラーがでました。開発時よりも↑の本番のほうがまだわかりやすいエラーなの、開発時はTurbopackとかが影響してんのかな。
まあ今まで動いてたので分かんないんだけど。

 ⨯ [TypeError: The "payload" argument must be of type object. Received null] {
  code: 'ERR_INVALID_ARG_TYPE',
  page: '/posts/nextjs_15_migration/opengraph-image.png'
}

Imgur

そもそも静的書き出しモードでOGP画像の生成はドキュメント通りに作るとダメで(Issueある)、代わりにOGP画像を返すRoute Handlersを作ることで静的書き出し時にHTMLとともに画像が生成されるようになります。
そのRoute Handlers機能も本当は静的書き出しモードでは利用できないのですが、条件を満たした(GETリクエストのみ + Requestの引数を使わない)場合は静的書き出し時に一緒に呼び出され書き出されるようです。

本題に戻して、OGP画像にSVG<img>経由で表示しているのですが、これがなんかエラーになってしまっている。
かなり端折っていますが、こんな感じに<img>SVGを表示させてた。<img>を経由してたのはpublic/にあるアイコンを読み出したく、ファイルパスより直接渡したほうが良いらしいので。

// 本当は opengraph-image.tsx を作って使います
// ImageResponse を返すことで OGP 画像を返せる Route Handlers が作れる。静的書き出し時はこっちで作る必要がある
export async function GET(_: Request, props: PageProps) {
    const params = await props.params;

    return new ImageResponse(
        (
            // 背景
            <div
                style={{
                    height: '100%',
                    width: '100%',
                    position: 'relative',
                    display: 'flex',
                    backgroundColor: '#fff'
                }}
            >
                {/* SVG は https://fonts.google.com/icons から。ありざいす */}
                <img
                    width={50}
                    height={50}
                    src={
                        `data:image/svg+xml;,<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M220-180h150v-250h220v250h150v-390L480-765 220-570v390Zm-60 60v-480l320-240 320 240v480H530v-250H430v250H160Zm320-353Z"/></svg>`
                    }
                />
            </div>
        ),
        {
            width: 1200,
            height: 630
        }
    )
}

マジでエラーが分からんのでNext.jsOGP画像生成で使ってるライブラリvercel/satoriのエラーを出してる箇所を見に行ったところ、
data:image/svg+xml;,だと間違いで、これでは正規表現が一致しなくて、追加でエンコーディングが何なのかをいれる必要がありました。
なので正解は→ data:image/svg+xml;charset=utf8,{SVGのxmlをここに貼る}

てかGoogleで調べたらutf-8まで入れてたわ。
ちなみにこのdata:から始まるやつ、Data URI (URL ?)とかいう名前がついているらしい。

Imgur

export async function GET(_: Request, props: PageProps) {
    const params = await props.params;

    return new ImageResponse(
        (
            // 背景
            <div
                style={{
                    height: '100%',
                    width: '100%',
                    position: 'relative',
                    display: 'flex',
                    backgroundColor: '#fff'
                }}
            >
                {/* SVG は https://fonts.google.com/icons から。ありざいす */}
                <img
                    width={50}
                    height={50}
                    src={
                        `data:image/svg+xml;charset=utf8,<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"><path d="M220-180h150v-250h220v250h150v-390L480-765 220-570v390Zm-60 60v-480l320-240 320 240v480H530v-250H430v250H160Zm320-353Z"/></svg>`
                    }
                />
            </div>
        ),
        {
            width: 1200,
            height: 630
        }
    )
}

because it took more than 60 seconds. Retrying again shortly.

Failedって出てビビったけど、しばらくした後にリトライしてくれるらしい。

Failed to build /posts/page/[page]/page: /posts/page/1 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/2 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/3 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/4 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/5 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.

Imgur

ただ、後述しますがGitHub Actionsの環境だとこれがよく出てしまってそうなので、タイムアウトを伸ばすようにしました。。

静的書き出しのタイムアウトを伸ばす

ドキュメントあった。
進捗ない場合はタイムアウトさせて再起動する機能がついたそう。

静的書き出しの高度な設定

どうやらNext.js 15からAdvanced Static Generation Controlという、静的書き出しの並列数等を制御する設定が実験的に追加されたそう。
本当にひどいようならここをいじるしか無い?

静的書き出し出来た

Imgur

本番更新

Imgur

GitHub Actionsが走っています

Imgur

ローカルと同じエラーが出たけどリトライしたら直った話

ローカルと同じエラーが出てしまった。タイムアウトを伸ばさないから、、、

Imgur

Imgur

とりあえず今までのCI5分以内で終わってたので5分、、、と思ったんですけどさっきのCI4分かかってるので2倍にした。

/** @type {import('next').NextConfig} */
module.exports = {
    output: 'export',
    // 省略...
    staticPageGenerationTimeout: 60 * 10 // 10分
}

で、伸ばした後リトライしたら逆にいつもの時間で終わった、どういうことなの

Imgur

でも何回かGitHub Actionsやってるけどやっぱりこのエラーが出る時は出てしまうので伸ばしておくことにしようと思う。

Failed to build /posts/page/[page]/page: /posts/page/1 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/2 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/3 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/4 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/5 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/6 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/7 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/8 (attempt 1 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/4 (attempt 2 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/6 (attempt 2 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/7 (attempt 2 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/8 (attempt 2 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/3 (attempt 2 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/1 (attempt 2 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/5 (attempt 2 of 3) because it took more than 60 seconds. Retrying again shortly.
Failed to build /posts/page/[page]/page: /posts/page/2 (attempt 2 of 3) because it took more than 60 seconds. Retrying again shortly.

OGP 画像が一部壊れている

なんでこんな部分的に壊れるの...?

Imgur

Imgur

ちなみにエラー。分からん、、

   Generating static pages
Failed to load dynamic font for 自由帳経動直 . Error: [TypeError: fetch failed] {
  [cause]: [AggregateError: ] { code: 'ETIMEDOUT' }
}
Failed to load dynamic font for 自由帳国内版入 . Error: [TypeError: fetch failed] {
  [cause]: [AggregateError: ] { code: 'ETIMEDOUT' }
}
Failed to load dynamic font for 自由帳作直際大変 . Error: [TypeError: fetch failed] {
  [cause]: [AggregateError: ] { code: 'ETIMEDOUT' }
}
   Generating static pages

なんかまたタイムアウト系なのでもう一回試したら動いた。
なんかすごい静的書き出しモードの調子悪い? →うそです。OGP画像の件に関しては私が悪かったです。

Imgur

   Generating static pages
   Generating static pages
 ✓ Generating static pages
   Finalizing page optimization ...
   Collecting build traces ...
   Exporting (0/3) ...
 ✓ Exporting (3/3)

これ次回以降もなにかの拍子に失敗する可能性があるってこと?
どうしたものか

Imgur

これに関しては私が悪かったです、原因はフォントファイルがWeb 向けの軽量版を使ってて、必要な文字が網羅されていないのが原因だった、
今まで気付かなかった。。。全部入りの方を使うようにしました。

Next.jsOGP画像を作る機能はvercel/ogを使っていて、そのライブラリはvercel/satoriを使っています。

なんでvercel/satoriを直接使っていないのか、vercel/ogは何なのかはこちらにまとまっていました。
ざっくり、フォントをGoogle Fontsからダウンロードする機能、satoriが吐き出したSVGpngにする等をvercel/og側でやっているらしい。

OGP 画像を作る時に @vercel/og を使うか satori を使うか迷ったログ | t28.dev

https://t28.dev/blog/vercel-og-or-satori-for-me.html

話を戻して、じゃあWindowsマシンで成功してGitHub Actionsで失敗してんのはなんでだよって話ですが、おそらくこれです。
そのフォントを指定しない場合(もしくはフォントファイルに文字がない場合)はGoogle Fontsからダウンロードするのですが、Windowsはいい感じにIPv4で繋いでくれたけどGitHub Actions(というか Linux)だとおま環で繋がらないIPv6の方を使ってしまってるのが原因の可能性がある。

いきなり話が広がりすぎやろがいって話ですが、WSL 2(Ubuntu)でビルドする際にIPv4を優先するオプションを付けるとだいぶエラーの数が減ったので可能性がある。減っただけでエラー自体はある
こんな感じにnodeのオプションをつければ良いはず?

NODE_OPTIONS='--dns-result-order=ipv4first' npm run build

もし私みたいな事になってしまったら、Web 向けの軽量版フォントを使うのを辞めるか、
Google Fontsをあらかじめローカルに落としておいて、satoriにフォントファイルとしてArrayBufferで渡すなどすれば良いのかな。
ちな上記のオプションIPv4を強制するものじゃないのでIPv6を使われたらやっぱりエラーになってしまう。

これ一時的にIPv6がダメだったり、Node.jsバージョンが関与してたり(?)でかなり複雑そう雰囲気。。。

おわりに

ちなみにReact側の修正は ないはずです。 私の場合はありませんでした

おわりに2

  • React 19use() Hook<Suspense>useEffect()を消し飛ばしたい
  • ダークモード切り替えに端末の設定に従うを追加したい
  • タグ詳細のページネーション
  • next.config.tsTypeScriptで書けるようになった
  • とか

やりたいんですが、あんまり決まってないのでとりあえずNext.js 15に上げただけのPRを書こうと思います。
Tailwind CSSも次のバージョンが控えてるそうなので今じゃなくていいか。

フロントエンド、むずかしい。

おわりに3

せいてきがえっちな方の漢字になってないかgrepしたけど大丈夫そう