カルキチブログ

Next.jsをやめて、Remixで技術ブログを作り替えた話

10ヶ月ぶりの更新です。
突然ではありますが、エンジニアになったばかりの時にNext.jsで構築した技術ブログをRemixでリプレイスしました。

Next.js全盛の今Next.jsを使うのをやめて、Remixにリプレイスした理由ですが、Remixを今後の自分の技術スタックの軸にしたいと考えたからです。

Remixについて

Remix is a full stack web framework that lets you focus on the user interface and work back through web standards to deliver a fast, slick, and resilient user experience.

引用: https://remix.run

  • Reactベースのフルスタックフレームワーク
  • GETリクエストはloaderで、GET以外のリクエスト(DELETE, PATCH, POST, or PUT)はactionと呼ばれるRemixが提供する関数を使用してサーバーサイドの処理を実装する。
  • レンダリング戦略はServer-Side Renderingのみ。(※後述するSPAモードを使用するとClient-Side Renderingも可能)

この記事ではNext.jsをやめてRemixで技術ブログをリプレイスしただけではなく、Remixを今後の自分の技術スタックの軸にするという決断に至った経緯やRemixで技術ブログを構築し直す過程で得られた知見などについて書いていこうと思います。

※以降Server-Side Rendering -> SSR、Client-Side Rendering -> CSRという略称を使用させていただきます。

Web標準に準拠した設計思想に共感できたから

Web標準に準拠した設計が特徴のRemixでは、Web標準の技術を使用して開発を進めることができます。
具体例を挙げると以下です。

  • データの取得をしたい: Web Fetch APIとRemixが提供するloader関数を使えば実現できる
  • データの作成、更新、削除をしたい: formとRemixが提供するaction関数を使えば実現できる。バックエンドに送るデータはFormDataで取得できる。
  • 認証状態やECサイトのカート機能のようなサーバー側で状態を保持する必要がある機能を実装したい: CookieとSessionを使えば実現できる
  • SSRのみだと表示速度が遅くなるから、ページの表示速度を速くしたい: Cache-Controlでキャッシュを調整すれば実現できる。

Web標準に則っているだけではなく、Webの枯れた技術を使用して開発を進めることができることがわかるかと思います。

Webの枯れた技術を使用して開発を進めることができるため、Webの基本が分かっている開発者であれば、比較的少ない学習コストでRemixを使うことができるというのも大きな魅力の1つです。

Remixが今後Web開発のスタンダードになっていくかどうかは現時点ではわかりませんが、Remixを学ぶことは、Webそのものの理解を深めることにも繋がると筆者は考えています。

Remixがたとえ廃れたとしても、Remixを使えるようになるために学習したWebの技術に関する知見は他の技術をキャッチアップする際にも生かしていけると考えたのも、Remixを使っていこうと考えた理由の1つです。

実際、技術ブログをRemixにリプレイスする過程で、Cache-Control周りの挙動の理解はだいぶ深まった気がします。

サービス特性によって、デプロイ環境やレンダリング戦略を柔軟に切り替えられるから

様々な環境にデプロイできる点やサービス特性(to C向け or to B向け)によって、レンダリング戦略を柔軟に切り替えることができるのもRemixを使っていこうと考えた要因の1つです。

デプロイする環境に縛られない

Remixはサーバー上で実行されますが、JavaScriptサーバに渡されるハンドラーにすぎないため、環境・ホスティングサービス問わず動作させることができます。

While Remix runs on the server, it is not actually a server. It's just a handler that is given to an actual JavaScript server.

It's built on the Web Fetch API instead of Node.js. This enables Remix to run in any Node.js server like Vercel, Netlify, Architect, etc. as well as non-Node.js environments like Cloudflare Workers and Deno Deploy.


引用: https://remix.run/docs/en/main/discussion/introduction#http-handler-and-adapters

RemixはNode.jsではなく Web Fetch API 上に構築されているため、Node.jsサーバーだけでなく、Cloudflare Workersのようなエッジランタイムでも実行できます。

公式ドキュメントにはRemixをExpressサーバで実行させるサンプルコードが掲載されているのですが、サンプルコードから、JavaScriptサーバに渡されるハンドラーにすぎないということが分かると思います。

const remix = require("@remix-run/express");
const express = require("express");

const app = express();

app.all(
  "*",
  remix.createRequestHandler({
    build: require("./build/server"),
  })
);

Remixでは以下のような様々な環境やホスティングサービスに対応できるようにするために、公式やコミュニティーから様々なアダプターが提供されているため、デプロイする環境に縛られないという点も優れています。

  • 公式が提供しているアダプター
    • @remix-run/architect
    • @remix-run/cloudflare-pages
    • @remix-run/cloudflare-workers
    • @remix-run/express
  • コミュニティーが提供しているアダプター
    • @mcansh/remix-fastify
    • @netlify/remix-adapter
    • @netlify/remix-edge-adapter
    • @vercel/remix
    • remix-google-cloud-functions

https://remix.run/docs/en/main/other-api/adapter#server-adapters

SSRやCSRをサービス特性に応じて切り替えられる

RemixにはSPAモードと呼ばれるモードが存在するのですが、このモードを使用することでRemixをSPAとして使用することもできます。

https://remix.run/docs/en/main/guides/spa-mode#what-is-spa-mode

to C向けのサービスであれば、高いパフォーマンスが必要になるケースも多いので、CDNキャッシュやエッジランタイムの恩恵を受けられますが、業務管理ツールのようなアプリケーションの場合そこまで高いパフォーマンス要件が求められないことが多いので、CSRで十分なケースも多くあります。

Remixの公式ドキュメントでも以下のように言及されています。

From the beginning, Remix's opinion has always been that you own your server architecture. This is why Remix is built on top of the Web Fetch API and can run on any modern runtime via built-in or community-provided adapters. While we believe that having a server provides the best UX/Performance/SEO/etc. for most apps, it is also undeniable that there exist plenty of valid use cases for a Single Page Application in the real world:

引用: https://remix.run/docs/en/main/guides/spa-mode#spa-mode

SPAでアプリケーションを構築する際にアプリケーションが最初に読み込まれた時にページで必要なJavaScriptの読み込みと実行が必要になるため、アプリケーションの規模が大きくなってくると、ページ全体が表示されるまでに時間がかかってしまうという問題が発生することがありますが、SPAモードでもルートベースによるcode-splitingをサポートしているため、bundleされたJSのコード量をある程度削減してくれます。

  • File-based routing (or config-based via routes())

引用: https://remix.run/docs/en/main/guides/spa-mode#what-is-spa-mode

SPAモードの場合ビルド時に静的な index.htmlファイルを生成し、データの読み込みと変更にはクライアントデータ API (clientLoaderclientAction等)のみを使用するため、サーバーサイドではないと実行できないAPIを使用することはできなくなりますが、サービス特性によって、レンダリング戦略を切り替えることができるのもRemixを使っていく決断を下した理由の一つになります。

Next.jsの早すぎる変化の流れについていくモチベーションが維持できなくなったから

Next.jsに対してApp Router以降、Server ComponentやServer Actionsが出てきたあたりから、言葉を選ばずに書くと、「なんでこんな面倒なことを考えないといけないのか...?」という感情が沸々と湧いてきて、考えた末に、Next.jsを積極的にキャッチアップするのをやめるという判断をしました。

筆者は過去に2度App Routerを採用したプロジェクトに携わった経験がありますが、fetchキャッシュの意図しない挙動や、Client Component、Server Componentの設計周りに悩むことが多くありました。

Remixだから変化の流れが早くないとかは思っていませんが、HTML生成戦略はSSRのみ、全てSSRにすることによって生じるレンダリングコストの増大とオーバーヘッドの回避はCache-Controlで調整するという実装の方がシンプルで魅力的に感じました。

今後のNext.jsに対する筆者の立ち回り方として、あくまで現状ではありますが、業務で触れる機会があれば使うけど、個人で積極的にキャッチアップするのはやめようという決断に至りました。

あったとしてもZennやX等で、流れてきた情報を流し読む程度に留めようと考えています。

Next.jsで必要 or 使いたい機能をRemixでも実現できることがわかったから

技術ブログを構築していく過程で得た気づきなのですが、Next.jsを使っていた時に必要 or 使っていた機能をRemixでも実現できることがわかったこともRemixを今後の技術スタックの軸にしていきたいと考えた理由の1つです。

いくつか例を挙げて説明していきます。

ディレクトリベースのルーティング

Remixのルーティングはデフォルトだとファイルベースのルーティングを採用しています。
v1.11でファイルベースのルーティングを追加され、v2で標準機能としてリリースされたようです。

https://remix.run/blog/remix-v2#highlights

# Remix Routing
.
└── routes
    ├── _index.tsx: /
    ├── post.tsx: /post
    ├── post.hoge.tsx: /post/hoge
    └── sample.tsx: /sample

小〜中規模のプロジェクトであればファイルベースのルーティングでも問題ないかもしれませんが、規模が大きいプロジェクトですと、app/routes/ 直下に全てのルーティングファイルが配置されると見通しが悪くなってしまう問題が発生するので、ディレクトリベースのルーティングを採用したいケースもあると思います。
→Next.jsに慣れていた筆者はこのファイルベースのルーティングに最初は戸惑いました。

# Remix Routing
.
└── routes
    ├── _index.tsx
    ├── post.hoge.fuga.tsx
    ├── post.hoge.tsx
    └── post.tsx

ディレクトリベースのルーティングを実現したいユースケースにもRemixは対応していて、remix-flat-routesというパッケージを追加することで、以下のようなディレクトリベースのルーティングも実現可能になります。

.
├── _index.tsx: /
├── post
│  ├── _index
│  │  └── index.tsx: /post
│  └── hoge
│     └── index.tsx: /post/hoge
└── sample.tsx

API Routes

Next.jsのAPI RoutesもRemixで実現可能です。
Remixではloaderのみを定義したファイルをroutesに配置すると、APIを実装することができます。

以下は/api/test にGETリクエストを送るとtestという文字列をJSONで返却するAPIの例です。app/routes/api.test.tsx に配置することでAPIを実装できます。

export async function loader() {
  return json({
    message: "test"
  });
}

GET以外のリクエスト(DELETE, PATCH, POST, or PUT)を実行する必要がある場合は、以下のように実装できます。

export { action };

export const action = async () => {
  // 処理を書く
}

export const loader = async () => {
  return json({ message: 'Method Not Allowed' }, { status: 405 });
};

actionのみを定義すると、GETでリクエストを実行した時に { "message": "Unexpected Server Error" }が発生してしまうため、loaderを定義して、GETリクエストも受け付けられるようにして、GETリクエストが実行された時は405 Method Not Allowedを返却するようにします。

SSR Streaming

SSR Streamingは段階的にSSRを実行しクライアントへ配信するレンダリング方法です。

SSRにはAPIサーバーの処理速度などの問題で、クライアントにHTMLが返却されるまでの時間が長くなってしまうというデメリットがあります。

SSR Streamingを使用することで、APIのフェッチが解決するのを待たず、レンダリングできる部分を先にレンダリングして、インタラクティブなページをできるだけ早くユーザーに表示することで、知覚される読み込みパフォーマンスの向上が期待できます。

RemixでもRemixが提供する deferAwait というコンポーネントを使用することで実現が可能です。

この技術ブログでもNext.js時代の全画面SGから、全画面SSRになったのを機にトップページを含む記事一覧とタグに紐づく記事を表示するページで、UXを向上させるために導入しています。

import { defer } from "@remix-run/node";

export const loader = async () => {
  // 記事一覧を取得する
  const { contents, totalCount, paginateNum } = await cmsUseCase.getPosts();
 // タグ一覧を取得する
  const { tags } = await cmsUseCase.getTags();

  // deferを使用すると、Promiseの完了を待たずに、クライアントにレスポンスを返却することができる
  return defer({
    // Awaitに渡すために、Promiseで返却する
    contents: Promise.resolve(contents),
    totalCount,
    paginateNum,
    tags,
  });
};

最近リリースされたReact Router v7だと、defer メソッドは非推奨になったので、代わりに生のオブジェクトを返すだけで良くなりました。

export const loader = async () => {
  // 記事一覧を取得する
  const { contents, totalCount, paginateNum } = await cmsUseCase.getPosts();
  // タグ一覧を取得する
  const { tags } = await cmsUseCase.getTags();

  return {
    // Awaitに渡すために、Promiseで返却する
    contents: Promise.resolve(contents),
    totalCount,
    paginateNum,
    tags,
  };
};
import { Await } from "@remix-run/react";

<Suspense fallback={<div>Loading...</div>}>
  <Await resolve={contents}>
    {(contents) => (
      <>
        <PostList contents={contents} />
        <Pagination {...{ paginateNum, totalCount }} />
      </>
    )}
  </Await>
</Suspense>

モジュールをサーバーサイドのみでしか使用できないようにする

Next.jsでは import 'server-only';とマークすることで、クライアントサイドで、サーバーサイドのモジュールをインポートしようとするとビルドエラーを発生させることができます。

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

Server Actionsを使用して、Next.jsをフルスタックフレームワークとして使用する場合はクライアントサイドとサーバーサイドのモジュールに境界線を設けることで、情報漏洩などのインシデントを防ぐことに繋がります。

Remixでも同等の機能が提供されており、以下のように定義したモジュールはサーバーサイドのみでしか使用できないようにすることができます。

  • .server 内に定義されたモジュール
  • *.server.ts という命名で定義されたモジュール

While not strictly necessary, .server modules are a good way to explicitly mark entire modules as server-only. The build will fail if any code in a .server file or .server directory accidentally ends up in the client module graph.

引用: https://remix.run/docs/en/main/file-conventions/-server

ドキュメントに記載されている通りなのですが、.server ファイルまたは .server ディレクトリ内のコードが誤ってクライアント サイドでimportされている場合、ビルドは失敗します。

稀なケースではありますが、特定のモジュールをクライアントサイドでしか読み込ませないようにすることも、Remixでは実現できます。

以下のように定義したモジュールはクライアントサイドのみでしか使用できないようにすることができます。

  • .client 内に定義されたモジュール
  • *.client.ts という命名で定義されたモジュール

https://remix.run/docs/en/main/file-conventions/-client

loaderやactionのテストを書く

プロダクトで技術選定をする上でテスタビリティを担保できるかどうかも、重要な判断基準になります。

RemixではcreateRemixStubを使用することで、loaderやactionを含むコンポーネントをテストすることができます。

https://remix.run/docs/en/main/utils/create-remix-stub

createRemixStubを使用すると、以下のようなstorybookのplay functionを使ったインタラクションテストを書くことも可能になります。

import { expect, within } from "@storybook/test";
import { createRemixStub } from "@remix-run/testing";
import Page from "../routes/sample";

import type { ComponentType } from "react";
import type { Meta, StoryObj } from "@storybook/react";

const meta = {
  title: "routes/sample",
  component: Page,
  decorators: [
    (Story: ComponentType) => {
      const RemixStub = createRemixStub([
        {
          path: "/",
          Component: () => <Story />,
          loader() {
            return {
              id: 1,
              name: "hoge",
            };
          },
        },
      ]);

      return <RemixStub />;
    },
  ],
} satisfies Meta<typeof Page>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
  play: async ({ canvasElement }) => {
    const { findByText } = within(canvasElement);
    await expect(await findByText("id: 1")).toBeInTheDocument();
    await expect(await findByText("name: hoge")).toBeInTheDocument();
  },
};

まとめ

この記事ではRemixで技術ブログをリプレイスしただけではなく、Next.jsを使うのをやめて、Remixを今後の自分の技術スタックの軸にしたいという考えに至った理由や、プロジェクトでRemixを導入する際に使えそうな機能や設計手法について紹介させていただきました。

Remixを本格的に使い始めて感じた所感ですが、Next.jsの複雑な複雑なキャッシュの挙動や、Client Component、Server Componentの設計周りを意識しなくても良くなったので、開発体験が良くなったように感じています。

Remixを採用しているプロダクトが現状少ないのがネックではありますが、Remixをうまく使いこなせるようになれば、Webの理解が今よりもさらに深まりそうなので、キャッチアップを続けていこうと考えています。

補足

技術選定に関してはオーバーエンジニアリングもいいところで、現在のブログのアクセス数(月平均約1,000件)や更新頻度を考慮すると、AstroHUGO、最近登場したHonoXのようなSGをサポートするフレームワークで十分だと考えています。

本ブログの技術選定はRemixというフレームワークの理解を深め、一定のスキルを身に付けるためのものであることを、補足させていただきます。