Open90

2023年11月ごろからはじめるNext.js

MaretolMaretol

仕事&プライベートでNext.jsを使うかもしれないから勉強しようと思った
これはその過程で得た知識やメモを残すスクラップである

場合によっては間違っていたり古いこともあるかもしれないので突っ込んでくれると助かる

MaretolMaretol

下記忘れてたけどフロントの経験としてNuxt.js/Vueはある
ただしバージョンは2系列が最後でSSRやSSGの経験は非常に薄い

あと本業はバックエンドエンジニア

MaretolMaretol

めっちゃ前に古めのReactを触った経験があったことを思い出した。
useState, useEffect, useContext あたりは引っかかるぐらいの知識はある

MaretolMaretol

環境

  • OS: Windows
    • 開発環境: WSL - Ubuntu
  • Editor: VSCode

各種バージョン

$ node --version
v18.18.2
$ npm --version
9.8.1

Nodeとnpmは新しいバージョンにしとけばよかった(昔入れたのを忘れてそのままやってた
幸い最新のNext.jsのバージョンを見るに node が 18.17 以上なら大丈夫っぽいのでそのまま進める

MaretolMaretol

プロジェクトを作る

$ npx create-next-app

これでいい。問題はこのコマンドを叩いたあとどんな設定したか忘れたこと(1週間ぐらい前に打った

環境としては

  • TypeScript を使用する
  • ESLint を使用する
  • /src ディレクトリを作成する
  • tailwindcss を使用する
  • App Router を使用する

あたりはあとから見て思い出せる

書いてない設定はデフォルトだった気がする

MaretolMaretol

Next.jsのバージョン書き忘れてた

package.json
{
  "name": "my-page",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "react": "^18",
    "react-dom": "^18",
    "next": "14.0.1"
  },
  "devDependencies": {
    "typescript": "^5",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "postcss": "^8",
    "tailwindcss": "^3.3.0",
    "eslint": "^8",
    "eslint-config-next": "14.0.1"
  }
}

こんな感じ
Next.js 14.0.1
React 18

MaretolMaretol

加えていくつか設定

  • .gitignore に .vscode を追加
  • .vscode/settings.json に以下の設定追加
settings.json
{
    "files.associations": {
        "*.css": "tailwindcss"
    },
    "editor.codeActionsOnSave": {
        "source.fixAll.eslint": true
    },
}

設定の内容は見ればわかるが、不要なエディターの警告やエラーを消すため
拡張機能としてはESLintとTypeScript、Tailwindのやつを入れる

Next.js/Reactの拡張機能はとりあえず後回しにする(なんかいい感じのが見つからなかった

MaretolMaretol

とりあえず起動してみる(VSCodeとかの設定の前にそっちだったわ

$ npm run dev

> my-page@0.1.0 dev
> next dev

   ▲ Next.js 14.0.1
   - Local:        http://localhost:3000

 ✓ Ready in 2.7s

この状態で localhost:3000 にブラウザでアクセス(起動はWSL上でやっているが、普通にWindowsのブラウザからアクセスして問題ない

 ○ Compiling /page ...
 ✓ Compiled /page in 2.3s (499 modules)
 ✓ Compiled in 184ms (237 modules)
 ○ Compiling /favicon.ico/route ...
 ✓ Compiled /favicon.ico/route in 1007ms (504 modules)

アクセスするとこんなロゴが出ながらブラウザにはページが表示される。問題なし

MaretolMaretol

で、いろいろ作っていく

まず大前提として機能面を調べたところ、日本語版とかはちょっと古い情報があって混乱した

  • Page Router と App Router の2つのページング機能がある
    • これは App Router のほうが新しい
    • 併用は可能
  • てきとーに調べてるとApp Routerでは使えない機能が出てきたりするので注意する必要がある
    • 後でまとめながらどの機能を使うかもメモっておく
    • 特に日本語版のドキュメントは最新のものに追従できてないようなので見ないほうがマシかも知れない
MaretolMaretol

ルーティングについて

  • Next 13 以降 App Routerが入り現状こっちが推奨されてるっぽい
    • のでまあ基本はこっちでやる
    • サーバーサイドコンポーネントに当たるらしい
    • レンダリングと処理に関係するところは後々触れると思う
  • ページ構成は app/ 下(あるいは src/app/ 下)のファイル構造がそのままページのパス構成になる
    • app/hoge/fuga -> example.com/hoge/fuga みたいな
    • よくあるやつなんで詳細は省略
  • ファイルはいくつか特別扱いされるものがある。それらは名前で決定される
  • ページの定義は上記の page.tsx でやるっぽい

というわけでデフォルトで作成された app/page.tsx をいい感じに変えてみる

page.tsx
export default function Home() {
  return (
    <h1>Hello World!</h1>
  )
}

で、 npm run dev

デフォルトのCSSがlayout.tsxで効いてるからか変な縞模様があるがまあできた

MaretolMaretol

縞自体は globals.css の body 部分を消せば消えるのでとりあえずそれで。現状いらないし

MaretolMaretol

せっかくなのでこんなことをしてみる

page.tsx
export default function Mainpage() {
  console.log("ここにログを出してみる")

  return (
    <div>
      <h1>Hello Next.js World!</h1>
    </div>
  )
}

上記と同じ app/page.tsx
このログはどっちに出るか。まあわかり切っているが、これはサーバサイドで実行されているのでブラウザのログではなくnpm run devしたコンソールのログに出てくる

イメージしやすくなる

MaretolMaretol

page.tsx の export default function Page() のメソッド名はなんでもいい?様子
要は export default された関数が表示される という認識。間違ってたらごめん

あとコンポーネントとして使用するときに呼び出し元が判別するためという感じだろうか

MaretolMaretol

app/subpage.tsx というファイルを作ってみる。中身はこんな感じ

subpage.tsx
export default function SubPage(){
  return (
    <div>
      <h2>Sub Page</h2>
    </div>
  )
}

んで app/page.tsx はこんな感じにする

page.tsx
import SubPage from "./subpage";

export default function Mainpage() {

  return (
    <div>
      <h1>Hello Next.js World!</h1>
      {SubPage()}
    </div>
  )
}

これで localhost:3000 にアクセスすると、 subpage.tsx の中身も表示される

んで、 localhost:3000/subpage とかにアクセスしても特に何も表示されない(404のページが出る)

そういう仕組みだと理解できる

MaretolMaretol

……で、ページのルーティングがわかったところでイメージ&思考実験

表示したいページのディレクトリを作り(例: /dashboard )、そこに page.tsx を作り、そこにページの記述をする。コンポーネントは、 /dashboard 内でしか使わないものと、汎用的に使いたいものがあるとする。そうなった場合、コンポーネントはどこに記述すべきか

例えば絶対 /dashboard 内でしか使わないな、というものであれば dashboard ディレクトリ内に記述するか?となるが、往々にしてプロジェクトの進行でそういうのは変わるので、やっぱり app ディレクトリと同階層に components ディレクトリを用意してそこに記述する気がする

こういうのは処理系だけ持っていくのが正解かもしれない。Nuxt.jsで言うところの <sript> タグの中身とか。UIと処理を分離したいという欲求に答えられるはず

MaretolMaretol

そういえば

page.tsx
export default function Mainpage() {
  console.log("test log")
  return (
    <div>
      <h1>Hello Next.js World!</h1>
    </div>
  )
}

これでログを出したときに、ページを更新するたびにログが出る

てっきりキャッシュされるから出ないと思っていたが、キャッシュされるのはレンダリング結果であってスクリプトではないのか。ちょっと調べる

MaretolMaretol

サーバーコンポーネントのレンダリングに関するの項目(How are Server Components rendered)にそれっぽいのが合ったので仮訳

On the server, Next.js uses React's APIs to orchestrate rendering. The rendering work is split into chunks: by individual route segments and Suspense Boundaries.

サーバ上で、Next.jsはレンダリングを統合するためにReactのAPIを使用します。レンダリング処理は複数のチャンクに分けて行われます。チャンクは個々のルートセグメントとサスペンスバウンダリー(サスペンスタグ?)によって分割されます。

Each chunk is rendered in two steps:

それぞれのチャンクは以下の2ステップでレンダリングされます

  1. React renders Server Components into a special data format called the React Server Component Payload (RSC Payload).
  1. ReactがReact Server Component Payloadと呼ばれる特定のデータフォーマットにサーバーコンポーネントを描写します。
  1. Next.js uses the RSC Payload and Client Component JavaScript instructions to render HTML on the server.
  1. Next.jsが描写されたReact Server Component PayloadとClient Component JavaScriptの内容を元にHTMLにサーバー上でレンダリングします

この続きはクライアントの話。一旦止めてここまでの内容をまとめると

  1. Reactがサーバサイドコンポーネントとして書かれた内容をRSC Payloadに変換する
  2. Next.jsがRSC Payloadとクライアントコンポーネントとして書かれたJavaScriptを処理してHTMLにする

ということらしい。キャッシュの話にまだ届いてないが、とりあえずRSC Payloadを知っておいたほうが良さそう

で、ちょうどいいことにこの項目の注釈にWhat is the React Server Component Payload(RSC)?というのがあるのでそれを読む。長くなったので次の投稿で

MaretolMaretol

The RSC Payload is a compact binary representation of the rendered React Server Components tree. It's used by React on the client to update the browser's DOM. The RSC Payload contains:

RSCペイロードは、React Server Componentsツリーの軽量なバイナリ出力です。ReactがクライアントサイドでブラウザのDOMをアップデートする際に使われます。RSC Payloadは以下の要素を含みます

  • The rendered result of Server Components
  • サーバコンポーネントのレンダリング結果
  • Placeholders for where Client Components should be rendered and references to their JavaScript files

クライアントコンポーネントがJavaScriptによってレンダリングしたり参照するためのプレースホルダー

  • Any props passed from a Server Component to a Client Component
  • サーバコンポーネントからクライアントコンポーネントに渡すプロパティ

要するにReactのサーバサイドコンポーネントの出力結果ということなのだろう
正直これだけじゃわからんのでReactのドキュメントを見るべきか

MaretolMaretol

思い当たるフシがあったのでそこを当たったら正解だった

npm run dev (すなわち next dev )は毎回アクセス時に生成していたようで、アクセス時にログが出ていたが、npm run build からの npm run start (すなわち next build からの next start )はビルド時にログが出て、startによって開始したサーバにアクセスするタイミングではログが出なかった

試しに

page.tsx
export default function Mainpage() {
  const now = new Date()
  console.log("test log: ", now.toISOString())
  return (
    <div>
      <h1>Hello Next.js World!</h1>
      <h2>Current time {now.toISOString()}</h2>
    </div>
  )
}

このコードを実行したところ、ビルド→スタートの場合は時間がビルド時のもので固定されていた

このあたりはキャッシュされているというより、正確に言うと「ビルド時に生成・実行されたもの」であって、キャッシュ可能なファイルといったほうがいいかもしれない

とにかくドキュメントの内容が理解できた&動作がわかったので一安心

MaretolMaretol

で、じゃあこれちゃんと更新したい(アクセスタイミングの時間が出るようにしたい)場合どうするの?という方向で調べていく。ついでにfetch関係も触るかも

MaretolMaretol

大前提として

  • static rendering(静的レンダリング)
  • dynamic rendering(動的レンダリング)

がある。静的レンダリングはビルド時にレンダリングされ、動的レンダリングはリクエスト時にレンダリングされる

page.tsx
export default function Mainpage() {
  const now = new Date()
  console.log("test log: ", now.toISOString())
  return (
    <div>
      <h1>Hello Next.js World!</h1>
      <h2>Current time {now.toISOString()}</h2>
    </div>
  )
}

これは静的レンダリングなので、ビルド時の時間が出ていたし、ログもビルドのタイミングで出ている(devビルドの場合はリクエスト時にレンダリングされている)

Next.jsのLearnのChapter 8で、このレンダリングが扱われている

https://nextjs.org/learn/dashboard-app/static-and-dynamic-rendering

ここでも静的レンダリングの場合ビルド時にレンダリングされると書いているし、実際リアルタイム性が必要なデータや、リクエスト時に内容が決定するもの(Queryとか)は動的レンダリングで処理する必要があると書いている

ではどうすればいいかというと、上記のLearnの中に書いているものだと unstable_noStore() を使う例が書かれている

他にもいくつか当たってみた感じでは、fetch API の cache オプションを適切に設定するといいっぽい

基本は静的レンダリングで、ビルド時に生成される。というのは覚えておいたほうが良さそう。というかそこまで理解してまずスタートラインっぽい印象

MaretolMaretol

というわけでとりあえずこうしてみる

page.tsx
import { unstable_noStore } from "next/cache"

export default function Mainpage() {
  unstable_noStore()

  const now = new Date()
  console.log("test log: ", now.toISOString())
  return (
    <div>
      <h1>Hello Next.js World!</h1>
      <h2>Current time {now.toISOString()}</h2>
    </div>
  )
}

当然 npm run build してから npm run start する

するとログはビルド時に出ず、アクセス時に出た。ページもアクセスした瞬間の時間が表示された

この unstable_noStore() が有効な範囲がどこまでなのか気になるのでちょっとこんなこともしてみる

page.tsx
import { unstable_noStore } from "next/cache"

export default function Mainpage() {
  const now = new Date()
  const noStoreNow = getTime()
  console.log("time: ", now.toISOString())
  console.log("noStore time: ", noStoreNow)
  return (
    <div>
      <h1>Hello Next.js World!</h1>
      <h2>Current time {now.toISOString()}</h2>
      <h2>Current noStore time {noStoreNow}</h2>
    </div>
  )
}

function getTime(){
  unstable_noStore()
  return new Date().toISOString()
}

この場合、どちらもアクセスした時刻が出た。Mainpage() 関数も unstable_noStore() が効いていることになる

MaretolMaretol

今度は src/lib/timer.ts というファイルを作ってそっちに分けてみる

timer.ts
import { unstable_noStore } from 'next/cache'

export default function getTime(){
  unstable_noStore()
  return new Date().toISOString()
}

んで、これを呼び出す

page.tsx
import getTime from "@/lib/timer"

export default function Mainpage() {
  const now = new Date()
  const noStoreNow = getTime()
  console.log("time: ", now.toISOString())
  console.log("noStore time: ", noStoreNow)
  return (
    <div>
      <h1>Hello Next.js World!</h1>
      <h2>Current time {now.toISOString()}</h2>
      <h2>Current noStore time {noStoreNow}</h2>
    </div>
  )
}

これを実行した場合でもやっぱり同じように時間が出る

MaretolMaretol

次はこんなことをしてみる

page.tsx
import Subpage from "./subpage"

export default function Mainpage() {
  const now = new Date().toISOString()
  console.log("mainpage time: ", now)
  return (
    <div>
      <h1>Hello Next.js World!</h1>
      <h2>Current time {now}</h2>
      {Subpage()}
    </div>
  )
}
subpage.tsx
import { unstable_noStore } from "next/cache";

export default function Subpage(){
  unstable_noStore()
  const subNow = new Date().toISOString()
  console.log("subpage time: ", subNow)
  return (
    <div>
      <h2>Subpage time : {subNow}</h2>
    </div>
  )
}

page.tsx と subpage.tsx は同じ階層のファイルである

ビルドしてスタートすると、ビルド時には mainpage の方のログが出力され、subpage の方のログは出なかった。アクセスしてみると、どちらもログが出て、どちらも現在時刻が出た

page.tsx はビルド時にレンダリングされ、 subpage.tsx はリクエスト時にレンダリングされると思っていたが(実際 page.tsx のログが出るタイミングを見るにビルドで一旦レンダリングされているっぽい)、リクエストするとレンダリングされ直されている

現状の理解では subpage.tsx の方だけアクセス時にレンダリングされると思っていたが違うらしい

次はこのあたりを掘ってみることにする

MaretolMaretol
page.tsx
import Subpage from "./subpage"

export default function Mainpage() {
  const now = new Date().toISOString()
  console.log("mainpage time: ", now)
  return (
    <div>
      <h1>Hello Next.js World!</h1>
      <h2>Current time {now}</h2>
      <Subpage />
    </div>
  )
}

Subpageの呼び出し方を変えても結果は同じだった

MaretolMaretol

ひとまずルートごとに判定されるというのを把握した上でこんなふうにしてみる

ファイル構成
src
|-app
   |-dynamicpage
   |   |-page.tsx
   |-staticpage
   |   |-page.tsx
   |-page.tsx

で、それぞれこんな感じでやってみる

app/dyanmicpage/page.tsx
import { getTimeNoCache } from "@/lib/timer"

export default function DynamicPage(): JSX.Element{
  const time = getTimeNoCache()
  return (
    <div>
      <h1>Dynamic Page</h1>
      <h2>Current time {time}</h2>
    </div>
  )
}
app/staticpage/page.tsx
import { getTime } from "@/lib/timer";

export default function StaticPage(): JSX.Element{
  const time = getTime()
  return (
    <div>
      <h1>Static Page</h1>
      <h2>Current time: {time}</h2>
    </div>
  )
}
app/page.tsx
import Link from "next/link"

export default function Mainpage() {
  return (
    <div>
      <h1>Hello Next.js World!</h1>
      <Link href="/staticpage" >Static Page</Link>
      <Link href="/dynamicpage" >Dynamic Page</Link>
    </div>
  )
}

これでビルドして動かしたところ、staticpageの方はビルド時の時間、dynamicpageの方はアクセス時の時間で表示された。この下に静的レンダリングのコンポーネントをおいてみる

MaretolMaretol

忘れてた

lib/timer.ts
import { unstable_noStore } from 'next/cache'

export function getTimeNoCache(){
  unstable_noStore()
  return new Date().toISOString()
}

export function getTime(){
  return new Date().toISOString()
}

これは src/lib をおいてそこに書いた

MaretolMaretol

こんなサブコンポーネントを用意する

app/subpage.tsx
import { getTime } from "@/lib/timer"

export default function Subpage(): JSX.Element{
  const subNow = getTime()
  console.log("subpage time: ", subNow)
  return (
    <div>
      <h2>Subpage time : {subNow}</h2>
    </div>
  )
}

それをstaticpageとdynamicpageの双方で呼び出す

app/dynamicpage/page.tsx
import { getTimeNoCache } from "@/lib/timer"
import Subpage from "../subpage"

export default function DynamicPage(): JSX.Element{
  const time = getTimeNoCache()
  return (
    <div>
      <h1>Dynamic Page</h1>
      <h2>Current time {time}</h2>
      <Subpage />
    </div>
  )
}
app/staticpage/page.tsx
import { getTime } from "@/lib/timer";
import Subpage from "../subpage";

export default function StaticPage(): JSX.Element{
  const time = getTime()
  return (
    <div>
      <h1>Static Page</h1>
      <h2>Current time: {time}</h2>
      <Subpage />
    </div>
  )
}

他は一個前のやつと同じ。ビルドしてスタートする

staticpageのパスではビルド時の時間が表示され、アクセス時もログは出ない
dynamicpageのパスではアクセス時の時間が表示され、アクセス時のログも出る

どうやら正しいらしい。ページ内でDynamic Renderingのコンポーネントが入っていたりすると、そのページの中身はすべてDynamic Rendering扱いになるようだ

これはつまるところ、気をつけないと同じコンポーネントでもDynamicで処理されるものとStaticで処理されるものの2つ出てくるわけだ

いまさらだけど初学者がいきなりNext.jsの13以上のバージョン触れてわかるんかこれ

MaretolMaretol

ここから先で抑えておかないといけなさそうなポイント

  • Dynamic Renderingの条件
    • このあたりはあれこれ読んでる間に割と頻繁に出てきてるからまとめるだけで良さそう
  • Client ComponentとServer Componentの併用
    • ビルド時の時間とアクセス時の時間を両方出すのは、Server Componentだけだと無理っぽいがClient Componentと併用すると行けるっぽいので実際にやってみる
MaretolMaretol

まずは改めてまとめる

ページは基本はStatic Rendering

有効な場所は

  • SEO関係で有効に使いたい(サーチエンジンのクローラー対応が楽
  • より高速なWebページを実現したい
  • サーバの処理を削減したいとき

また、UIにデータが関わらない、あるいはユーザーごとに共通したデータのみである場合有効。こちらはCDN配信等も対応できる

Dynamic Renderingは

  • リアルタイム性のあるデータを扱うとき
  • ユーザ別のデータを扱うとき
  • リクエスト時のデータを必要とするとき

に向いている。向いているというかそういうときに使えって感じ

ではどういうふうに作るとどちら側になるのか。いい感じの表が公式ドキュメントにあったので訳して転載

Dynamic Function(動的関数?) データ ルート
なし キャッシュあり Static Rendered
あり キャッシュあり Dynamic Rendered
なし キャッシュ不可 Dynamic Rendered
あり キャッシュ不可 Dynamic Rendered

ルートでStatic/Dynamicが書かれているが、これはちょっと前までいろいろ触って調べたのでその部分にふれる必要はあるまいて

つまるところ

  • 動的関数がなく、データのキャッシュが有効な場合のみStatic Rendered(静的レンダリング)
  • それ以外全部Dynamic Rendered(動的レンダリング)

という感じの様子
動的関数というのはさっきまで触れていた unstable_noStore() のような関数のことを指していると思われる。Next.jsのAPIの分類であるのかもしれない

MaretolMaretol

動的関数?は後で調べることにしようと思った(というかまとまってはない?っぽい
ドキュメントの各関数の項目を見るしかなさそう
ただ、サーバサイドでのfetchとかは該当するっぽい。そしてfetchにはfetchでcacheの設定でまた何か色々変わるぽいことが書かれてる。たいへーん

一旦離れて client component を見ることにする

MaretolMaretol

Client Component の特徴

  • ブラウザのAPIが使える
    • サーバサイドからだと使えない
    • 例:localstorageとか
  • インタラクティブなページ作成ができる
    • state, effect とかが使える
    • ReactのuseStateとか使いたかったらServer Componentでは無理

こんな感じか

ちなみに既存のpages routerはクライアントコンポーネントだったっぽい?(詳しく調べてない

ではapp routerでクライアントコンポーネントを作るのはどうするかというと

component.tsx
'use client'

// 以下省略

とすればいいらしい。このuse client宣言はServer ComponentとClient Componentの境界で宣言するそうだがその境界ってのがどこなのか若干わかりにくい

コードを書いて実際に動かしたほうがはやそう

MaretolMaretol

書いてる途中で import 文のあとに 'use client' を入れたところ、ファイルの頭に書けとビルド時に怒られたのでそういうことらしい

MaretolMaretol

上の方でやっていた「ビルド時の時間」と「リクエスト時の時間」を表示するページを作ってみる

当然だがビルド時の時間はServer Componentでないと実現できず、リクエスト時の時間はClient Componentでないと実現できない はず

app.tsx
import ServerSide from "./serverside"
import ClientSide from "./clientside"

export default function Mainpage() {
  return (
    <div>
      <h1>Hello Next.js World!</h1>
      <ServerSide />
      <ClientSide />
    </div>
  )
}
serverside.tsx
import { getTime } from "@/lib/timer"

export default function ServerSide() {
  const buildtime = getTime()
  return (
    <div>
      <h2>build time : {buildtime}</h2>
    </div>
  )
}
clientside.tsx
'use client'

import { getTime } from "@/lib/timer"

export default function ClientSide() {
  const requesttime = getTime()
  return (
    <div>
      <h2>request time : {requesttime}</h2>
    </div>
  )
}

getTime() はいくつか前の処理と同じで、new Date().toISOString() を返すだけの関数

結論として確かにサーバサイドの buildtime はビルド時の時間を、requesttime はリクエスト時の時間を表示している。更新したら当然ながら requesttime の方だけ変化するというのがわかった

ただ、エラーがコンソールに出ているのでちょっとそっちもたどる
どうやら同じメソッドが使われているのに返す値が合ってないエラーっぽい

Text content does not match server-rendered HTML

らしい

MaretolMaretol

メソッドじゃなくてサーバサイドでの描画結果とクライアントサイドでの描画結果がズレていることのエラーっぽい

まあずらすのが本望なことをしているのでこのエラーは一旦スルーする

ちなみにHTMLの記述が不正だったりすると起きるケースが多いっぽい(pタグの中にdivタグをおくとか

MaretolMaretol

解決法として、クライアントサイドでやる処理を useState で変数定義し、処理を useEffect で行うことで回避できる

clientside.tsx
'use client'
import { useEffect, useState } from "react"

export default function ClientSide() {
  const [requesttime, setRequesttime] = useState('')

  useEffect(() => {
    const requesttime = new Date().toISOString()
    setRequesttime(requesttime)
  }, [])

  return (
    <div>
      <h2>request time : {requesttime}</h2>
    </div>
  )
}

これならサーバサイドでレンダリングした初期状態は '' で一致して、クライアントサイドでコンポーネントを表示するタイミングで useEffect が発火し requesttime が書き換えられ表示が更新される という理屈なんだと思う

いややっぱこれ初学者混乱しそうだな……難しいフレームワークだと思う

MaretolMaretol

Server ComponentとClient Componentで、どちらを使うべきかというのは公式がまとめてくれている

https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns

とりあえず触りで出てくる表だけまとめると

用途 Server Component Client Component
データ取得 x
バックエンドリソースへの直接のアクセス(DBとか) x
センシティブな情報(APIキーとか)を扱う x
大きな依存関係を持つ / クライアントサイドでのJavaScriptを減らしたい x
インタラクティブ性を入れたり、イベントリスナーを使いたい x
stateのライフサイクル(useState, useEffectとか)を使いたい x
ブラウザAPIを使いたい x
state, effect, ブラウザAPIに依存したカスタムフックを使う x
React Class componentsを使う x

という感じらしい(カスタムフックの項目とかは当然では……?って思うのだが

公式ドキュメントのこの項は表に続いて、Server ComponentやClient Componentでのパターンが載っているのでそこも抑えておくべきだろうし次はこれを読む

MaretolMaretol

Server Components Patterns を軽くまとめ

  • コンポーネント間でのデータ共有
    • React Contextの代用(React Contextはクライアントサイドでしか使えない
    • fetchのメモ化
      • サーバコンポーネントでのfetchは同一のAPI相手だった場合自動的に結果を共有しAPIへのアクセス回数を減らしてくれる
        • 例えばヘッダー、メイン部分、サイドパネルのようにUI別にデータが必要だけど場合によってはあったりなかったりするパーツに対してすべてのコンポーネントでユーザーデータを取りに行くように書いても勝手に効率化してくれる という感じっぽい
  • サーバでしか動かしたくないコードをユーザ環境に持っていかない
    • APIキーとか
    • これらの処理がクライアントサイドにもれないように、server-only というパッケージがある
      • そのうちファイル先頭に 'server only' って書くようになりそう(これはパッケージなので import 'server-only' と書く
  • サードパーティのコンポーネントやパッケージを利用する際は、Server Componentが新しい技術であることも含めてクライアントサイドでしか動かないケースが多い
    • そのため、サードパーティのコンポーネントをラップしてClient ComponentにしてからUIに組み込むと無駄が出にくい

ってところだろうか
実際UIでよく使われるサードパーティのコンポーネントはほとんどがServer Componentに対応してないらしいので工夫する必要がありそう

MaretolMaretol

続いてClient Component Patternsのまとめ

  • できる限りコンポーネントツリーの下層におくこと
    • JavaScriptのバンドルサイズを減らすため
  • Server ComponentとClient Componentは疎結合にする
    • 例えばUIサブツリー上で、Client Componentのコンポーネントツリーの下にServer ComponentやServer Actionsの呼び出しをネストするのは、可能だが注意点がある
      • Request/Responseライフサイクルにおいて、まずClient Componentがサーバからクライアントに渡され、そこでのサーバサイドへの呼び出しは再度新規のリクエストを送ることになる
      • 詳しくはサポートしてないパターンとかを見ろ

新規リクエストのくだりはちょっと訳がわからず省略
ただあまり良くない理由は察することができる

というわけで次はUnsupported Patternsを見る

MaretolMaretol

ちょっと逸れた話で雑談&考えをまとめる

zennのトップページとかでいろいろ見ているとNext.js13以降の記事をみつけたのでせっかくだしいろいろ見てみた

当然ながらNext.js13以降のバージョンではサーバコンポーネントをうまく使えと言っており、これによりキャッシュを利かせページを高速化させサーバの負荷も軽くしようという意識が見える。いろいろな見方はあるが、コンテンツを軽量にしクライアントに負荷をかけず、かつサーバも負荷をかけないのは全面的に正しいことである。昨今某社の携帯回線が遅いとか端末のバッテリーの問題とかもあるので通信も処理も軽いに越したことはない(正確にはそのあたりの問題は動画等のコンテンツが主な要因だが、とはいえ、だからといって湯水の如くリソースを使用することを是とするのはエンジニア・デベロッパを名乗るものとしては醜悪な態度である)。

で、今までの(バージョン13以前の)Next.jsやReactを触っていた感じからして、処理の中には state を利用していたものが少なくなかった。例えばページ内タブなどでどのタブを選択しているかは state の中に情報を持たせていたし、ユーザ情報なども layout の内部で init を走らせてグローバルなステートに保存し、それを各コンポーネントが参照するという使い方も多かった(と思う。よくよく考えると自分はフロントのエンジニア経験が少ないのでぶっちゃけ間違っているかも)。

しかし、サーバコンポーネントではこれらの機能は使えない。なんせ state はクライアント限定である。加えて、前のポストにある通りクライアントコンポーネント内にサーバコンポーネントをもたせるのはあまりいいパターンではないように見える。事実、タブ分けだけクライアントコンポーネントで state をもたせ、stateに従ってサーバコンポーネントを取りに行くというのはあまり良い実装には思えない。

じゃあどうやってんの?となると、どうもパスパラメータやクエリパラメータを利用するのが王道のようで、要はURLに状態をもたせようということらしい(ただしうまくやってあげないと静的なサーバコンポーネントにならない)。妥当でもありつつ、ある意味Webの原点回帰のような実装だが、そもそも現在のWebページにおけるインタラクティブ性は上がり過ぎであり、同じURLが同一の内容を指していないように見える(厳密にはGETの内容は同一で冪等性が保たれている)点は問題があった気もする。なんというか直感的ではない。

ただし、URLに状態をもたせるとしても限度はある。ページ内タブとか、セレクトボックスとか、ラジオボタンとかのチェックは可能でもユーザ情報などはそこに置くことはできない。とはいえ、ユーザ情報に関してはAPIをすべてのコンポーネントで叩いても問題ない(サーバコンポーネントのfetchは自動的にメモ化されるので)し、やはりそこにある障害は別解による解決可能な障害という結論になる。なんなら、サーバコンポーネントは実質バックエンドなのでKVS系のDBを併用すれば state 管理などというめんどくさい要素を全部データベースに逃がすこともできるわけで、無理やりグローバル変数に近いデータ置き場を作るよりむしろそっちのほうがアーキテクチャとしては遥かに正しい気もする(無論、これはデータベースをグローバル変数的に扱えと言っているわけではない)。

とはいえ、方法が変わるためこれは今までReact/Next.jsを触っていた人もそれなりに大変な思いをすることになりそうだなぁとも思ったが。そのうち闇の魔術で state と似たようなことをするメソッドを持ち込む輩が出てくるのだろうか。

今まで何度か初学者は絶対苦労すると言っていたが、ぶっちゃけ歴戦の開発者も苦労すると思う。

MaretolMaretol

Unsupported Patternの項目はSupported Patternとセットになってた

  • サポートしていないパターン
    • Server ComponentをClient Componentで呼び出すこと
  • サポートしているパターン
    • Server ComponentがPropsとしてClient Componentに入っていること

という違いがあるらしい。サンプルコードは公式ドキュメントの通りなのでそっちを見てねって感じ

噛み砕いていくと、Client Componentのファイルの中で import ServerComponent from ... とやってはだめよという話
コンポーネントの構造上でネストはできるが直接組み込んではいけないよと言った感じ。子要素としてchildrenの構造体に渡すのは問題ない様子(公式のサンプルコード参照

まあ大体わかったので次

MaretolMaretol

とりあえず大雑把にNext.jsとReactの動きがわかったのでそろそろものを作る方に移ってもいい気がする

なんとなく取りこぼしてる感のある部分は以下の感じ

  • fetch関係
    • プラスそれのキャッシュ
  • 動的ルートの静的レンダリング条件
    • 例えばブログページ作るとして新規ページっていつレンダリングされんの?とか
  • デプロイ関係
    • これはまあそのタイミングでいいでしょうと思って後回しにしてる

ページを作りながら何か知見があったら書き足していく形で運用する

MaretolMaretol

ブログ的なページを作るとして考えたいこと

  • コンテンツ管理
    • これはヘッドレスCMS使おうと思う
  • UIライブラリ
    • App Routerに対応してるやつってどのくらいあるの?という疑問

このあたりが引っかかるが、とりあえず簡易的なサイトだけ作ってしまってなんとかしよう

MaretolMaretol

そういや env ファイルってどう読み込むのと思ったらどうやらおいてあるものを勝手に読んでくれるらしい

https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables

プロジェクト内の .env とか .env.local を勝手に読んでくれるほか、.env.prd.local みたいに node_env がついている場合も対応している

呼び出すときは process.env.HOGE みたいにすればおk

優先順はここの通り

ちなみに create-next-app を使うと .gitignore に .env*.local が最初から入っている。当然だけど env ファイルを git push しないように

MaretolMaretol

そういや当然だけど process.env の呼び出しは client side ではできない

MaretolMaretol

とりあえずブログっぽい感じにしたいので記事IDのようなものをパスパラメータに入れる形にしたい

https://example.com/blog/[article_id] みたいなイメージ

/blog のパスの部分は別のページも置く想定だけどここは事前に想定しているもので考える。ひとまずは/blog だけ作る

src/app
├── blog
│   └── [article_id]
│       └── page.tsx
├── favicon.ico
├── globals.css
├── layout.tsx
└── page.tsx

こんな感じ
とりあえず適当は article_id を流し込んだらそれを表示するページを作ってみる

MaretolMaretol

とりあえずあっさりめに

src/app/blog/[article_id]/page.tsx
export default function BlogArticlePage({
  params,
}: {
  params: {article_id: string}
}) {
  return (
    <div>
      <h1>ブログページ</h1>
      <p>{params.article_id}</p>
    </div>
  )
}

公式のサンプルコードに従ってこうなりますよといった感じ。実際はarticle_idからブログの内容を取ってくる必要があるけどとりあえずこれで動的ルーティングが動く感じ

MaretolMaretol

試しに generateStaticParams を使ってみる

src/app/blog/[article_id]/page.tsx
export default function BlogArticlePage({
  params,
}: {
  params: {article_id: string}
}) {
  return (
    <div>
      <h1>ブログページ</h1>
      <p>{params.article_id}</p>
    </div>
  )
}

export async function generateStaticParams(){
  return [
    {article_id: "test_id_1"},
    {article_id: "test_id_2"},
    {article_id: "test_id_3"},
  ]
}

これでビルドしてスタートすると、blog/test_id_1 などにアクセスしたときはログに時間は出ないものの、blog/test_id_4 など設定していないパスにアクセスするとログが出る

ちなみに一度ログが出ると更新してもログが出ないので、キャッシュが効くみたいである(キャッシュでいいのかな?

MaretolMaretol

どのタイミングでキャッシュが切れるかは要確認だが、ブログ記事をCMSから取ってくるに当たって更新時にちゃんと対応しないといつまでもキャッシュが残ることになる。キャッシュ設定はデプロイ時などに見ようと思うので今はとりあえずそのままで

ただ、 generateStaticParams を無理に使う必要はない(一度アクセスがあればキャッシュが残るのでそっちに任せてもいい)気がする

MaretolMaretol

ui components には shadcn/ui を使ってみた。Server Side Components にそれなりに対応してそうだったので

https://ui.shadcn.com

コンポーネントの追加が手動なのが面倒だけど必要以上に入れないようにすれば問題なさそう

MaretolMaretol

ページの状態を元に動作させたい場合、基本的に Client Side Components になる。たとえば下みたいな機能を使う場合

  • useState
  • useEffect
  • useContext
  • usePathname
  • useParamas
  • useSearchParams
  • useRouter

などなど。「ページがこの状態だったらボタンはdisableで……」みたいなことをする場合、全部クライアント側で処理させることになる

この考えを頭に入れると「ページの状態によって動作が変わるコンポーネント」というのはSSGとは相性が悪い。もちろん完全に排除できるものでもないが

たとえば「共通のヘッダーを作ってページごとで動作を変えて……」というのは相性が悪いっぽい。「共通のヘッダーなら全ページで共通の動きをさせろ」という感じだろうか。アンカー的なものも、アンカーで現在の項目部分の色を変えるとかは向いてないという理解でいいのだろうか(アンカー自体はURLに対応させればSSGでもいけるはず

今までのSPA的な考えで物を作っていたのでかなり修正が必要なのを感じる

MaretolMaretol

コンポーネントをいわゆるアトミックデザイン的に考えていたが、あんまり相性がよろしくない気がしてきたので切り替える。とりあえず調べようか

MaretolMaretol

再利用するコンポーネントとしないコンポーネントを考える

そもそもApp Routerでは components/... みたいなディレクトリを置かずとも、ファイルベースのルーティング下に置いてしまえば問題ない。app/Component.tsx みたいなファイルを作り、それを app/page.tsx から呼び出せばいいわけである

そうなると使いまわさないものはルーティングのディレクトリ(app下)から分ける必要はなく、使うところと同じ階層で定義してあげればいい

逆に使い回すコンポーネントがあった場合、そのコンポーネントは別の components/ 下に置くようなことをしてもいい。ただし、その場合でもServer Side ComponentsなのかClient Side Componentsなのかが影響する気がするのであっさり目に済むものでもない きがする

MaretolMaretol

fetch処理を行う場合、クライアントから行うのとサーバから行うのでは形が違う
サーバサイドの場合サーバ同士で通信できればいいが、クライアントからの通信ではクライアント側からのアクセスになり、これはブラウザの各種機能が動くことになる

その場合、CORSの問題とかクライアント側だけで発生する。発生してるどうしよう
Next.jsのサーバがプロキシになってくれると嬉しいんだけどそんな機能あるんだろうか

MaretolMaretol

クライアントからのアクセスをプロキシ的に処理したい場合、使うのは nextConfig の rewrites の方で redirect の方ではない

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/api/:path*',
        destination: 'http://admin.example.com/api/:path*',
      }
    ]
  }
}

module.exports = nextConfig

これでプロキシ的に動いてくれる

余談だがhttp-proxyを使う場合バグがあるらしくちゃんと動いてくれないらしい

MaretolMaretol

ちなみにこれ、環境次第で良し悪しがある気がする

たとえばAWSとかGCPのコンテナ環境で動かす場合、Next.jsのサーバーで転送するよりURLマッピングがある場所で振り分けた方がいいだろうし(ロードバランサとか
そっちの方が効率が良くコンテナに負荷がかからないだろうしレスポンスも早くなるのでは

ただバックエンドのコンテナとNext.jsのコンテナ(フロントのコンテナ)が別れてない場合はNext.js側で一旦受け取ってあげてという処理はありな気はする

この処理はキャッシュ効かない(と思う)が、キャッシュを効かせたいならバックエンドからアクセスするように作るべきなんだろう おそらく

MaretolMaretol

とりあえず色々やって色々作ってる(個人サイトだけじゃなく仕事でもNext.js14以降が使えるタイミングがあったのでそっちも含みながら)が、現状発生した問題は以下の2つ

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

まずこれ。本来 fowardRef() を使ってrefを渡してあげないといけないところを直接渡してしまってる問題。ただしどこで起きてるかわからない(refを渡しているコンポーネントはない)
多分ライブラリとの兼ね合いか、どっかの設定ミス

あと普通に ref がよくわかってないからそこも含めて要勉強

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components

Inputが uncontrolled なのに controlled になっちゃったよ問題。un/controlled の違いは Input の中身をDOMが持つ(uncontrolled)かreactのstateが持つか(controlled)と認識してる(これは間違いないはず

react-hook-form を使っているのでこれもどこかしらの設定か、何かしらのフラグが抜けてるのだと思う


あと現状エラーログを見てもどのコンポーネントで発生した事象かわからないケースが多いのでデバッグツール等をちゃんと導入すべきな気がする。ひとまず一通り実装を終えたらそっちを調べてエラーを解消する

MaretolMaretol

shadcn/ui の form を使用した場合、テキスト入力の数値が文字列型扱いになってバリデータが働きうまく通らないケースがあった

const FormSchema = z.object({
    param: z.coerce.number()
})

フォームの内容を定義する際にこのように数値型を強制するように定義してあげると通るっぽい。本来はFormの設定を使うようだが、これは shadcn/ui は対応してないっぽい?

MaretolMaretol

ログに出てたエラーを全部消せたので各種解決法

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

まずこれは Button コンポーネントをラップしたコンポーネントを作っていたのだが(今後の互換性等でラップした方がいいと思った)、それが悪さしていたらしい。とりあえずラップをやめた(そもそもなんでUIライブラリ使ってんだよってなるし

それで解決

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components

こっちは少し手こずったのだが、react-hook-formを利用した際に、defaultValuesを () => Promise<FieldValues> で渡した場合、(おそらく)非同期での処理完了より先にフォームの描画が実行されるのが原因だった様子

ちなみにこのエラーは defaultValues が null や undefined だった場合に起こるらしいので、通常は初期値を当てはめるだけで大丈夫。ただ今回の場合は非同期処理の実行があったため少しややこしいことになった

解決法としては loading の state を用意し、ロード中はフォームが描画されないようにして、データの fetch が完了次第フォームを描画するように変更した。それでエラーの表示がなくなったので解決

MaretolMaretol

大雑把にできたので自前のコンテナでデプロイできる場所で動かす

環境構築自体については概ね公式のドキュメントとかサンプルを見ればわかるので問題なし。たぶん。実際に動かさないとわからんところはあるので動かして確認

とりあえずビルドで躓いたところがあるからそっちを先に扱う

MaretolMaretol

ビルド時にfetchする部分をどうするか問題がある。Next.jsはfetchのタイミングがページ生成に合わせて3つのタイミングで存在していて

  • ビルド時
  • レンダー時
  • レンダー後

となっている。上二つがサーバサイドでのアクセスで、一番下がクライアントでのアクセス

サーバサイドのアクセスは、文字通りビルド時はビルドのタイミングでアクセス、レンダー時はサーバサイドでアクセスがあったタイミングでサーバサイドからアクセス、レンダー後はクライアントサイドからのアクセスとなっている

問題はビルド時のアクセスで、APIがプライベートなもの・認証が必要なものだった場合、ビルド環境が認証できていないといけない

さてどうする(認証できてないCI環境でビルドしている

MaretolMaretol

アプローチ案

  1. 認証を通す
    • GCP環境だしCIもGithub Actionsだしデプロイ関係の認証があるので楽
    • APIサーバの呼び出し権限あげれば解決する
  2. レンダー時・レンダー後のアクセスに切り替える
    • 対応は楽
    • ページの速度が落ちちゃう

とりあえず1で対応してみることにする

MaretolMaretol

1.がそこそこめんどそうだと分かったので2.に移行する

MaretolMaretol

'use server' はモジュール単位と関数単位の二つの範囲指定ができる

モジュール(ファイル?)の先頭で 'use server' をおくか、関数の1行目に 'use server' を置くかで区別される

サーバコンポーネントから呼び出す場合はどちらでも対応しているが、クライアントコンポーネントから呼び出す場合はモジュール単位のみしか対応していないらしい

とりあえず fetch が実行される部分を関数化して 'use server' を入れたら解決したので次

MaretolMaretol

で、ビルドは成功してデプロイ段階だがうまくいってないのでなんとかしないといけない

現状の問題として

  • npm run build で実行したビルド成果をどう扱えばいいのか
  • npm run start で開始するために必要なものがわからない

という問題にぶち当たっている

MaretolMaretol

next.config.jsにstandaloneの設定を入れないとダメっぽい

MaretolMaretol

next.config.js の Config に以下のような設定を入れる

next.config.js
const nextConfig = {
    ...
    output: "standalone"
}

んで、ビルドする。成果物ファイルは標準では .next/ ディレクトリの下にできるものがそれらしい。その中で、上記の設定を入れると .next/standalone ができているはずなので、それを持ってくれば動くらしい。実行時はそのディレクトリの中の server.js を叩いてあげれば起動する

つまりDockerfile的に書くとこんな感じ

Dockerfile
FROM node:20 as builder

...(いろいろ設定とかCOPYとか)

WORKDIR /app
RUN npm run build

FROM node:20 as runner

COPY --from=build /app/.next/standalone ./

CMD ["node", "server.js"]

なぜか公式ドキュメントだと .static を別途コピーする必要があるっぽいので(これは standalone のディレクトリに含まれているっぽいのだが)より厳密に公式ドキュメントと同じように書くと

Dockerfile
FROM node:20 as builder

...(いろいろ設定とかCOPYとか)

WORKDIR /app
RUN npm run build

FROM node:20 as runner

COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static

CMD ["node", "server.js"]

となる

MaretolMaretol

これは完全に余談だけど

現状の環境がGCPのCloud Runを利用する想定で、デプロイ環境にはGCPの公式イメージで用意しているdistrolessのコンテナを使用しているのだけどあのコンテナは最後の CMD に渡すパラメータは実行する server.js だけでいい

知らなかった

MaretolMaretol

環境変数をいい感じに扱いたいところが出てきたのだがなんか調べてると standalone のビルドだと明確な仕様がないっぽくて下手なことをすると不意に動かなくなったりしそうで怖い

https://zenn.dev/cumet04/scraps/f1ee61d0e83161

とりあえずこのスクラップを参考にしていろいろ試すことにする

MaretolMaretol
  • まずなぜ環境変数の話になったか
    • APIサーバへの問い合わせを考えているため
    • APIサーバは、開発時はDocker内のネットワークを参照し、SaaS環境ではINTERNALなネットワークでのアクセスを想定している
    • さらに、APIサーバはクライアント側からのアクセスも発生する
  • 解決法は環境変数でいいのか?
    • よくない気がする(真顔
    • というかよくない変に無理矢理な解決をしようとして沼ってる気がする

じゃあ違う解決法を考えましょう

  • 前提
    • Next.jsはAPIにアクセスする(fetchする)タイミングが3つある
      • 前のポストにもあるが、クライアント、サーバー(リクエスト時)、サーバー(ビルド時)
      • それぞれで扱いも目的も違う
  • アクセス経路を考える
    • クライアントからのアクセス
      • →通常のアプリケーションのドメイン経由
      • 認証が必要だが、何かしらのサービスで実装するためインフラ寄りの考えで対応できる
      • 開発時もSaaS時も、同様にホストを叩く(localhostだったりapiサーバのドメインだったり
    • サーバー(リクエスト時)のアクセス
      • →サーバだけが知れる経路での呼び出しで可
      • 開発環境のDocker Composeならコンテナにつけた名前でいい
      • SaaSなら内部用の呼び出しURLでいい
    • サーバー(ビルド時)のアクセス
      • →CI等が呼び出すなら認証や名前解決が必要になる
      • 面倒だしで上記ではリクエスト時のアクセスに逃げた

なんとなく古い慣習でURLのホスト部分等を抜いたパスのみでの指定で書いていたが、それが良くなかった気がする

そう言えばドキュメントに乗ってる fetch のサンプルは大体ホスト名やプロトコルの部分も全部書いてたしそれが推奨されるということなのでは

MaretolMaretol

で、ローカルから呼び出す時に別のportのlocalhostを指定するとCORSのエラーが起きる

忘れてた

MaretolMaretol

ローカルは nextConfig の rewrites を使う

基本的に、全ての環境において

  • ClientからのアクセスはNext.jsのページもAPIサーバの処理も同一ドメイン
  • サーバは特別にAPIサーバを名前解決する必要がある

この点において基本仕様は変わらないと判断する

MaretolMaretol

もう一個わかったこと

'use server' のモジュール(関数)がビルド時に実行されてた

リクエスト時の処理になってなかったっぽい。この辺の厳密な分岐わからんな。多分コントロールし切れるようになってないんだと思うけど

しょうがないのでその部分は client の処理に切り替えて対応する。クローズドだったり認証が必要なAPIからのデータ取得は client に任せるべきかも(なんかそういうのがドキュメントにあった気もする

MaretolMaretol

とりあえず standalone でビルドした Next.js プロジェクトをデプロイするところまでできた
意外と問題が起きなかった印象

server.js と static のデータだけ用意してあげれば問題ないっぽい

ただenv周りはややめんどそうだったので今後原因にならないことを願いつつ

MaretolMaretol

だいたいうまくいってもう書くこともないからクローズしようかなと思ったけど今度は認証を実装する必要が出てきたのでまだまだ続く

Next.js14において認証を実装するなら素直にNextAuthかauth0あたりを利用すべきなんだろうけど今回はfirebase authを利用する
そして以下の記事が出てくる

https://zenn.dev/kazukazu3/articles/fe07cc72647368

一筋縄では行かないかもしれない


幸いなことに、認証が必要なリクエストは全てクライアントサイドに実装している(先の問題の都合)。サーバコンポーネント等で実装されている部分は認証がなくても問題ないためとりあえずそこは解決できそう

ではクライアントサイドから認証処理を通し、トークン等を持ってバックエンドにアクセスする処理を書けばいいわけだ

とりあえずそういう処理を書くならカスタムフックだよなぁと思い次からカスタムフックでログイン回りのロジックを実装していく

MaretolMaretol

ひとまず layout.tsx に auth 関係の処理を入れたらエラーになった。そりゃそうだ。クライアントで動かさないといけないんだからクライアントコンポーネントに持っていかないといけない

そもそもフックはクライアント側じゃないと動かん

MaretolMaretol

https://nextjs.org/docs/app/building-your-application/authentication

公式ドキュメントにいい感じに認証関係の説明があったで読んでる
サーバサイドの処理もNext.jsに持たせる感じで書いてるので情報の取捨選択が大変

どうもカスタムフックを使うのではなく middleware.ts と言うファイルを置いてそこでログイン状態をとって処理を流している

ついでに見た感じまともに認証を作るならライブラリを素直に使った方が良さそう

MaretolMaretol

https://nextjs.org/docs/app/api-reference/file-conventions/middleware

https://nextjs.org/docs/app/building-your-application/routing/middleware

middlewareでの処理で実装すればいいのだろうか、と思いつつ調べていくとこれの動作はEdge Runtimeというやつらしい。ここにきて新しいのが出てくるの何

https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes

ここでEdge Runtimeがあるが、どうもこれはEdgeコンピューティングのプラットフォームのことっぽい

うまく処理する方法ってもしかしてないのでは

MaretolMaretol

とりあえず middleware.ts を書いてどこで動くか確認する

middleware.ts は app ディレクトリと同じ階層に設置する必要があり、 src ディレクトリを使用していたらその中、使用していなかったら(おそらく十中八九で)プロジェクトのルートの場所になるっぽい

少なくともdev環境ではサーバサイド扱いのようで、それは先頭に 'use client' を書いても同じだった。また、すべてのアクセスで動くようで、favicon等の静的ファイルのリクエスト時も動いていた。ちゃんと動かすならパスを指定してページアクセスの時だけ処理をするのが適切っぽい

ただ、いずれにせよこれはクライアント側ではないのでここに firebase auth の処理はかけないはず。ここからどうするか考えるが、考えた方法の一つとして

  1. middleware.ts で認証されていないかどうかを判断
  2. 認証されていない場合、 /login のようなパスに転送する
  3. /login のクライアントコンポーネントで auth 処理を動かす

とかなら行けるのではないか

ここまで書いてて思ったけど今の最終目標がちょっとぶれていて調べづらいので先にそっちをまとめ切ろう

MaretolMaretol

そもそもどの認証をどう組み込みたいのか

  • 組み込みたいもの
    • GCPのIdentity Platform
  • やりたいこと
    • これ を Next.js 上に組み込みたい

補足

  • なぜIdentity Platformなのか
    • 諸事情でOIDCを使ってのログインを実装したい
    • OIDCのクライアントをSaaSに寄せたい
    • バックエンドの環境がGCP
  • できるのか?
    • 理屈上できるはず
MaretolMaretol

前提

  • Client Component で諸々の処理を実装してあげる必要がある
    • 必要な処理自体はあまり多くない
      • 今回行うのはOIDCでリダイレクトを用いた認証タイプ
      • 問題はどこで呼び出すか

解決法として今思いついたのは「すべてのページで参照されるクライアントコンポーネントの中で実装する」という方法

そんなコンポーネントあるんか?って感じたが、たとえばヘッダー等でユーザーを表示するならそのコンポーネントが処理を持てばいい。データはクッキーに置けば再描画等での問題もないのでは

そういう UserStateComponent みたいなコンポーネントを用意してあげれば良さそう

MaretolMaretol

どのページにも置かれるようなコンポーネントに処理を実装したところ、ちゃんと転送された

が、リダイレクトから帰ってきたあとにうまく処理が通らないようで、認証情報を読めず再度ログインページに転送しようとする挙動が発生する

今回の処理の話と若干離れるのでとりあえずその辺りのあれこれはここでは省く

MaretolMaretol

standaloneだとenvを読み取ってくれない問題に当たった
当たったというかちょっとクセがあるっぽかった

状況

  • 実行環境: dockerのコンテナ
  • ビルド: standalone mode
  • 環境変数は NEXT_PUBLIC のものと通常のもの

やったこと

  • 実行環境のコンテナ内に定義した環境変数
    • →読まれず
  • ビルド時に環境変数を設定
    • →読まれず
  • 実行環境の server.js と同じ階層に .env ファイルを設定する
    • →読まれた

standalone じゃない場合はもうちょっと楽になるはず(どうも調べた感じそうっぽい
standalone だった場合、ビルド時にいい感じに .env ファイルを作成してあげてそこに環境変数を格納するのが確実っぽい

MaretolMaretol

呼び出し時にレンダリングされるServer ComponentからClient Componentを呼び出すように実装した時、子コンポーネントにパラメータを渡さないようにしたら高速化した(気がする

今までDynamic RoutingのパラメータをServer Componentから渡すようにしていたがuseParamsを使ったらそうなった。多分これは普通に悪い使い方だったっぽい

一応メモで残しておく