NewsPicks Stage.のApp Router移行の一年を振り返って

こんにちは。ソーシャル経済メディア「NewsPicksNewsPicks Stage. 事業のエンジニアをしています、林です。 業務では Next.js / Rust / Go などを用いて、経済・ビジネス情報に特化した動画配信サービスである NewsPicks Stage. の開発・運用を行っています。

概要

NewsPicks Stage.(以下Stage.)では去年末ごろから Next.js のフロントエンドを App Router へ段階的移行を進めてきました。

tech.uzabase.com

1年ほどで 半分以上のページが App Router 化 され、知見や感想が溜まってきたので、本記事では以下のような観点で振り返ってみます。

  • 移行初期

  • 習熟期

  • 現在

この記事は、Next.js App Router を多少触ったことがある方を対象とし、 Next.js 14系 を前提としています。

移行初期

配信視聴画面のリプレイスをきっかけに、App Router への移行がスタートしました。この際、既存のページを App Router 化するのではなく、新規ページを App Router で作成する 形で進めています。

移行にあたり、以下の計画を立てました:

  1. Local Storage に保存していたセッション情報を Cookie ベースに変更

  2. CSS Modules を採用、旧体系の emotion ベースの Component は利用しない

まず最初に、ServerComponent での Fetch に認証情報を利用するため、セッション情報の JWT を Cookie に持たせるようにしました。

それまでの Local Storage に JWT があることのセキュリティリスクや、取り出したJWTを明示的に Header に乗せてリクエストするその手間の解消などもありますが、そもそも ServerComponent で認証情報が扱えないのはかなり柔軟性を欠くことになるため、取り組みました。

JWT 移行の実装

JWT を Cookie に移行する際、Stage. 共通のヘッダーや配信画面で利用される API エンドポイントに JWT を付けてリクエストを行い、Cookie がない場合に Set-Cookie を返すようにしました。 今回はこの実装でほとんどのユーザーをカバーできると判断しました (api/set-cookie的な専用の Endpoint を作ってもいいかもしれません)

import { cookies, headers } from 'next/headers';
 
export async function GET(request: Request) {
  const auth = headers().get('authorization'); // 古いJWT
  const token = cookies().get('token');        // 新しいCookie
  
  // 検証やデータ取得処理

  return Response.json(data, {
    status: 200,
    headers: { 'Set-Cookie': `token=${token.value} ...` },
  });
}

Local Storage から Cookie への移行処理をフロントエンドで完結できたのは良いポイントでした。

CSS Modulesを採用、旧体系のemotionベースのComponentは利用しない

Pages Router では emotion を採用していましたが、App Router では CSS Modules を選択しました。

emotion に大きな問題点はなかったのですが、emotion 側が継続的に App Router への対応を進めるかわからなかったことがネックなのと、 emotion の Cache Provider を自前実装し、”use client”をつけて回ることになるため(もちろんその場合はパフォーマンス的な利益を得にくくなります)、CSS Modules の採用に至りました。

これは、Stage. の Web の規模と、そもそも新しくページを作るというタイミングだったのでできた意思決定ではありました。大きなProjectではここも段階的に行うことになるかもしれません。

既存 Component を利用しないことも同時に決めたので、若干追加の開発工数も必要でしたが、既存 Component の見直しにも繋がりました。


なお emotion は現在も対応中ステータスのようです。

Styling: CSS-in-JS | Next.js

移行初期を振り返って

知識面

移行初期は「新しい技術で新しいページを作る」というモチベーションもあり、試行錯誤しながら進めていました。しかし、技術理解のばらつきや初期の手探り感は否めませんでした。 チームメンバーの中では習熟度も異なっていて、ただペアプロなので実装は進んでいく・・・という状況でした。(Stage.開発チームはXP(extreme programming)を取り入れており、常時ペアプロをしています。)

各種ワードが結構紛らわしく、「ServerComponent」「ServerAction」は何が違うのか。「Api Routes」と「Route handler」は何が異なるのか。など、かなり初歩的な内容からメンバー間で認識することから始めるべきでした。 *1

特に App Router の場合、Server Action の Endpoint が露出することや"use server"をつけたことによる無駄な API 露出、ServerComponent -> ClientComponentへのProps の露出など、誤った使い方はセキュリティの危険に直結します。

App Router を今から始めるぞ!という方は、上に挙げた「誤った使い方」を説明できるようになってから取り組んでもらいたいです。

プロダクトの変化

App Router 化により、さまざまなことがシンプルに実装できるようになりました。

loading.tsx などはその例で、ファイルを配置するだけで今まで各所に実装していた {isLoading && <Loading />} が不要になります。 また Intercepting routes などで CSR の表示と直アクセスでの遷移の表示を別 UI にすることも比較的簡単に実装可能です。

それらを踏まえ仕様について話す際、「こういうことも可能です」「こういう時はどうしますか」というコミュニケーションも増えました。もちろん Pages Router のころから出来たことですし、会話もありましたが、FWの機能として提供されると関心が向くのは良いなと感じていました。

習熟期

App Routerで作った新しい機能もリリースされ、ある程度思い通りにできるようになってきた時期です。 この頃は以下のような挑戦を行って、いろんな経験値も増えた時期でした。

  • Parallel Routesを活用しソースコード上の関心をうまく分離する

  • 既存のSSGページをApp Router側でSSGするようにする

  • 管理画面(別のNext.jsプロジェクトです)もApp Router化に挑戦

明確に技術選定のメリットも分かってきたので、以下でいくつか具体例を示します。

Parallel Routesの良さ

App Router を導入することでまず良さを感じたのはParallel Routesでした。 簡単に言うと、一つのパスで複数の Page を Render するもので、各 Page はもちろん ServerComponentとして Fetch もできるため(データ取得も並列!)疑似マイクロフロントエンドのようなイメージで扱う事ができます。

以下は公式の例です。 /にアクセスした場合、analytics には app/@analytics/page.tsx が、team には app/@team/page.tsx が Render されます。childrenapp/page.tsx です。 (@analytics などは Named Slots と紹介されています)

.
├── app
│   ├── layout.tsx            <-- 共通のレイアウト
│   ├── page.tsx              <-- children として渡る
│   ├── @analytics
│   │   └── page.tsx          <-- analytics に渡る
│   └── @team
│       └── page.tsx          <-- team に渡る
// app/layout.tsx

export default function Layout({
  children,
  team,
  analytics,
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <>
      {children}
      {team}
      {analytics}
    </>
  )
}

Routing: Parallel Routes | Next.js

認知負荷の低下

App Router は Colocation(関連するコードを近い場所に置く)することが多いと思いますが、Parallel Routes を利用すると同じパスのものであっても関心の遠いものが離れて置かれていきます。 今までの Pages Router の Colocation では、同じパスの中で利用される Component を、意識的にフォルダに名前をつけて振り分けていたと思いますが、「ここ Parallel Routes でいけるな!」という事がわかるとその意識が必要無くなり、より直感的になります。

先の例でいうと、app/@analytics/_components/app/@analytics/_actions/のようにディレクトリが構成されていきますが、これらは app/@team/page.tsx を触る際は感知しなくてすみます。 この認知負荷がないことは意外と馬鹿にならないな、という実感があります。

# 実際のコードベースの一例。
.
├── @insights
│   ├── _components
│   ├── _drivers
│   ├── page.module.scss
│   └── page.tsx
├── @liveInfo
│   ├── _components
│   ├── page.module.scss
│   └── page.tsx
├── @popups
│   ├── _components
│   ├── page.module.scss
│   └── page.tsx
├── layout.module.scss
└── layout.tsx
Composition

個人的に感じるのは、今まで ReactComponent のリファクタでやっていたコンポジションのパターンを自然にできるのがいいなと思っています。 共有部を Layout に配置し、PageComponent が肥大化するのを自然と防げます。

// 今までCompositionでスッキリさせていたコード

function SplitPane(props) {
  return (
    <div className="SplitPane">
      <div className="SplitPane-left">
        {props.left}
      </div>
      <div className="SplitPane-right">
        {props.right}
      </div>
    </div>
  );
}

function App() {
  return (
    <SplitPane
      left={
        <Contacts />
      }
      right={
        <Chat />
      } />
  );
}

コンポジション vs 継承 – React (新しいサイトで見つけられませんでした)

// Parallel Routes
// app/layout.tsx
export default function Layout({
  left,
  right,
}: {
  left: React.ReactNode;
  right: React.ReactNode;
}) {
  return (
    <div>
      <div>{left}</div>
      <div>{right}</div>
    </div>
  );
}

// app/@left/page.tsx
export default function LeftPane() {
  return <div>Left Pane Content</div>;
}

// app/@right/page.tsx
export default function RightPane() {
  return <div>Right Pane Content</div>;
}

// app/page.tsx
export default function Page() {
  return null; // 中央部分は使わないので空
}
Component間を疎結合にする

Parallel Routes では、状態を URL に押し出すことで、リロードや直リンク時にも状態を維持できます。以下は、ボタンを押してフィルターを切り替える例です。

このコードでは、それぞれの Component が URL の QueryParam にのみ依存しているため、疎結合な関係になっています。

// app/layout.tsx
export default function Layout({
  children,
  filters,
}: {
  children: React.ReactNode;
  filters: React.ReactNode;
}) {
  return (
    <div>
      <div>{filters}</div>
      <div>{children}</div>
    </div>
  );
}

// app/@filters/page.tsx
// urlのParamを変更するだけのボタン
"use client";
import { useSearchParams, useRouter } from "next/navigation";

const filterOptions = ["all", "active", "completed"];

export default function Filters() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const currentFilter = searchParams.get("filter") || "all";

  const updateFilter = (filter: string) => {
    const params = new URLSearchParams(searchParams.toString());
    params.set("filter", filter);
    router.push(`?${params.toString()}`);
  };

  return (
    <div>
      {filterOptions.map((filter) => (
        <button
          key={filter}
          onClick={() => updateFilter(filter)}
        >
          {filter}
        </button>
      ))}
    </div>
  );
}

// app/page.tsx
// urlのパラメータを元に画面表示
export default async function Page({ searchParams }: { searchParams: { filter: string }}) {
  const filter = searchParams.filter
  const data = await fetch(`/api/${filter}`).then(res => res.json()).catch(() => []);

  return (
    <div>
      <h1>Filter: {filter}</h1>
      <div>{JSON.stringify(data)}</div>
    </div>
  );
}

一方で今まで State で管理していた値を誰でも変更可能ということでもあります。 上記の例では、filterOptions の型をつけていますが、どんなパラメータでも受ける可能性があります。

App Routerが及ぼすコード上の効果

App Routerでの開発を進めている中で、コード上の変化にも気づき始めました。単に新しい機能を使って便利になったというだけでなく、チーム開発におけるコミュニケーション等にも影響を与えるものでした。

関心の分離

特に強く実感したのは、「関心の分離」が自然に進むという点です。Parallel Routes の導入によって、同じ Path 内でも Page を分割して、Layout や Slot に分けることができるようになり、これまで漠然と「分割しなきゃな」と思いながら進めていた設計がスムーズに進むようになりました。

たとえば、「Page が肥大化してきたな」と感じたときも、「Layout に移しましょう」と自然に話が進むようになりました。これまでなら「どの範囲を共通化すべきか」「どこまでが再利用可能な部分か」を事前に考えすぎてしまうことが多かったのですが、今では必要なタイミングで必要な場所に移せばいい、という感覚で作業を進められるのは大きな変化です。

もちろん、何でもかんでも分割する必要はありません。layout.tsx も必須ではないので、まずはシンプルに実装を進め、必要に応じて分けていけば十分です。この「過剰な分割をしない自由度」と「必要な時に分割できる柔軟性」のバランスは、チーム開発において非常に助かるポイントだと思っています。

早すぎる抽象化の防止

Colocationを自然に進めていく中で、「早すぎる抽象化」を防ぐ効果もあると感じました。

Pages Router の頃は、Colocationを行っておらず、commons/hookscommons/lib といった共有ディレクトリにすべてを詰め込む形になりがちでした。結果として、特定のページだけで使うはずの Hook でも共有ディレクトリに置かれてしまい、「これは他のページでも使うべきなのか?」「不要な依存を生んでいないか?」といったことが不透明でした。

App Router では、各 Page, Slot ごとに _hooks_lib を配置するようになったことで、特定のページやスロットに関連するコードが分かりやすく整理されました。「この Hook はこのスロット専用」ということが明示されるため、不要な共有感を感じることもなくなりました。例えば、@dashboard/_hooks/useDashboardData.ts のように、あるスロット専用であることが一目で分かる配置になっていると、安心してコードを読めます。

Pages Router 
.
├── commons
│   ├── hooks
│   │   ├── useAuth.ts          <-- 全体共有
│   │   ├── useFetch.ts         <-- 全体共有
│   │   └── useDashboardData.ts <-- 本当は dashboard だけで使う
├── pages
│   ├── index.tsx
│   ├── dashboard.tsx           <-- useDashboardData を使用
│   └── profile.tsx

App Router
.
├── app
│   ├── @dashboard
│   │   ├── _hooks
│   │   │   └── useDashboardData.ts <-- dashboard に特化
│   │   └── page.tsx
│   ├── @profile
│   │   ├── _hooks
│   │   │   └── useProfileData.ts   <-- profile に特化
│   │   └── page.tsx

さらに、もし「この Hook を別のページでも使いたい」と思った場合、そのタイミングで初めてディレクトリ構造を見直せばいいだけです。このように、抽象化を後回しにできる土台があることで、必要以上に「共通化」や「再利用」を急ぐ必要がなくなり、結果としてシンプルで直感的なコードベースを維持できています。

この点は App Router というより Colocation の効果ですが、自然にこれを達成できるというのは利点です。

習熟期を振り返って

BFFの存在

以前の Stage. では、getServerSidePropsが肥大化していました。 Web から直接複数のAPI (もしくはCMS)を呼び出すような実装になっていたためです。ページ固有のロジックが getServerSideProps 内に集中しており、テストしにくく、再利用性が低下している状態でした。

そのためもともと BFF の必要性はチームとして感じており、実際 App Router 移行と同時に BFF を導入したのですが、振り返ってみると App Router での設計において、 BFF の存在はかなり大きかったです。

App Router において、ユーザーがあるパスに訪れた際の初期 API リクエストは、1対1の関係ではなくなりました。各 Page でそれぞれの関心のあるリソースを取得する構成になります。そのため、Pages Router 時代よりも多くの部分最適な API を作成していくことになります。API の再利用性と開発コストを考えると、Atomic な API を作っておき、BFF でそれらを集約できるのがやはりバランスがいいなと感じます。

今回 Stage. では利用しませんでしたが、変換・集約のロジックを Route Handler に持たせ BFF 的に扱うことも可能です。もし App Router 移行を検討している方がいたら、1層挟むことをお勧めします。

ルール作り

この時期を振り返ると、開発のスピードも上がってくる中でルール作りを改めて注力すべきだったなと思います。具体的に悩んだのはこの辺りです。

  • Server Component 内で fetchを書いていいのか、それとも変換を行う層を挟むのか。

  • client(ブラウザ) からの Fetch は変換を行う層を挟むのか。

  • useEffect 内で ServerAction を呼び出していいのか。

  • Server Action の返り値はどういう形か。

  • layout で Fetch するのか。

App Router の各機能はかなり便利で、狙ったように動いてくれます。そして同じ機能に対して実現の方法がたくさんあります。 関心は分離され、より部分最適になったコードベースの全体感をつかみ、「ここのコードいつもと違うな」ということを後から把握するのは、次第に難しいことになっていくのです。

実際、プログラミングふりかえりにおいて「Webのデータ取得方法があちこち違いすぎる」という問題を取り上げるに発展し、現状を整理する会を行ったりもしました。

通常とは別に、プログラミングを主題に振り返る"プログラミングふりかえり"を定期的に行っています

現在

チーム開発への影響

これは意外なことだったのですが、チームでの開発に良い影響を与えていると思います。

App Routerになり、FWそのものの複雑さは増したのは間違いないと思いますが、同時に規約を知っておけば生産性が上がるような下地が揃っていると思います。Parallel Routes により個別最適化が進み、多少不慣れでもそのPage、Slotに集中して実装すればいいという環境を作りやすくもあります。

「設定より規約」なFWには賛否両論あるように感じますし、個人的にも思うところはありますが、Next.jsの立場は「規約を強制しない」ように感じます。(App Router以降、より規約によっているとは感じますが)例えば、layout.tsx の存在がその一例で、必要に応じて自由に使うことができます。

この柔軟性があるからこそ、迷いをなくすようなチーム内でのルール作りは今まで以上に細かいものが求められると感じます。

Next.js の進化への対応

Next.js の開発は盛んで、進化も早いです。App Router の複雑さを踏まえると挙動の変更についていくのはなかなか大変です。

この1年で Next.js は v15 になりました。v15から、Cache のデフォルト挙動も大きく変わりました。Next.js の Cache は今まで暗黙的だったので、その時にどう影響を受けるか説明できる必要があります。それはおそらく、MigrationGuide だけでは把握しきれないはずです。

もちろんどのFWもキャッチアップや理解は必要ですが、Next.js はそれがより求められると感じます。ただ、このハードルを乗り越えるだけの価値があるとも思っています。

Pages Routerとの共存

1 年間、Pages Router と App Router を共存させる形で運用してきましたが、特段大きな問題には直面していません

ページ間遷移がハードになるので、getInitialProps などを利用している場合は少し気をつけたほうがいいかもしれません。 また、_app.tsx などを共存できない関係上、Project で一回初期化したいものはどうしても Pages 側、App 側両方に書く必要が出てきます。

旧体系のコードは使わない」という方針を徹底したことが大きかったです。これにより、新しいページを App Router で開発しつつ、旧体系のコードに引きずられることなく移行を進められています。

将来にむけて

Stage. のプロダクトの規模はまだまだ小さいですが、このコードベースが拡大していくとどのくらいの認知負荷になるかは未知数です。また、並列リクエスト数も比例して増えていくはずなので、パフォーマンス面の影響が顕在化してくるかもしれません。

特にGETリクエストを行うのは Page だけ・・・などルール決めはしておいたほうがいいかもしれません。 いくら個別最適とはいえ、枝葉の Component で Fetch し始めると収拾つかなくなる未来しか見えません。

そもそもがNext.js の Cache 機構を活用する前提で設計されている仕組みだと感じるので、スケールするにはそれらを生かす必要がありそうです。

まとめ

App Router への移行を進めて 1 年以上が経ちましたが、その利便性や開発生産性の向上を十分に感じています。

本当の意味で使いこなすと言えるレベルまで使いこめてはいませんが、「よく考えられているな」と思えることが多く、自然に認知負荷を減らしスッキリしたコードを書け、ユーザー体験にもつながるような体験は App Router 特有だと思います。

ただ App Router を活かすなら、 Pages Router での設計は見直す必要があり、個別最適な API を呼び出す都合上、その範囲は BFF/API にまで及びます。BFF がない場合、集約・変換する様な層があるほうがいいかもしれません。またセッション情報を Cookie ベースにしておきたいですし、CSS ライブラリも移行が必要かもしれません。

この記事が、App Router の移行を検討している方や同様の課題を抱えている方の参考になれば幸いです。

*1:もちろん今は、全員説明できます

Page top