Remixの新しいプロジェクトを立ち上げたばかりなら、あることにすぐ気づくはずです。publicフォルダの中に、<link>タグを書き込むためのindex.htmlファイルが存在しないのです。Remixにおけるfavicon設定を正しく行うには、ドキュメントの<head>に対する考え方を少し変える必要があります。従来のReact SPAなら、ルートディレクトリに.icoファイルを放り込んで終わりでした。しかしRemixは異なり、ドキュメントのメタデータを管理するためにルートベースのエクスポート(Exports)を多用します。

GitHubのブラウザタブを見てください。単なる静止画像ではなく、システムテーマに応じてfaviconが切り替わり、未読通知があるときは青いドットが動的に表示されます。Remixでこのレベルの洗練されたUIを実現するには、フレームワークの「魔法」に頼るのではなく、アセットを明示的に定義する必要があります。

Remixの哲学:暗黙的よりも明示的を好む

appディレクトリ内のfavicon.icoやicon.svgを自動でスキャンするNext.js App Routerとは異なり、Remixは明示的な宣言を好みます。個人的にはこのアプローチを強く支持します。魔法のようなファイルルーティングは動いている時は便利ですが、アイコンのキャッシュがおかしくなった途端、フレームワークのソースコードを掘り下げる羽目になるからです。

Remixでは、LinksFunctionエクスポートを使用します。この関数は、RemixがHTMLの<link>タグに直接マッピングするオブジェクトの配列を返します。faviconはアプリケーションの全ページに表示される必要があるため、これを配置する論理的な場所はapp/root.tsxファイルのみとなります。

ステップ1:アセットの準備

コードを書く前に、実際の画像ファイルが必要です。2026年現在、15種類もの異なるサイズのアイコンを生成する必要はありません。publicディレクトリに配置すべきファイルは以下の3つだけです。

高解像度のロゴしか手元にない場合は、Mzu favicondlに通してください。この正確なモダンスタックを生成し、SVGファイルを肥大化させる不要なメタデータを削除してくれます。

ステップ2:root.tsxでのLinksエクスポート

app/root.tsxファイルを開きます。デフォルトのRemixテンプレートを使用している場合、スタイルシートをエクスポートするlinks関数がすでにあるはずです。この配列を拡張して、faviconスタックを含めます。

import type { LinksFunction } from '@remix-run/node';

export const links: LinksFunction = () => [
  // レガシーフォールバック
  { rel: 'icon', href: '/favicon.ico', sizes: '48x48' },
  // モダンなスケーラブルアイコン
  { rel: 'icon', href: '/icon.svg', type: 'image/svg+xml' },
  // Appleデバイス対応
  { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }
];

Remixはこれらのオブジェクトを受け取り、ドキュメントの<head>内にレンダリングされる<Links />コンポーネントに注入します。ここで重要なのは順序です。ブラウザは上から下へ読み込むため、モダンブラウザはSVGをサポートしていれば、正しくSVGを優先して読み込みます。

ステップ3:ダークモードの対応

ロゴが黒の場合、ユーザーがブラウザをダークモードに切り替えると完全に消えてしまいます。Remixのlinks配列は有効なHTMLリンク属性をすべて受け入れるため、mediaプロパティを使用して、ユーザーのシステム設定に基づいてアイコンを切り替えることができます。

export const links: LinksFunction = () => [
  { rel: 'icon', href: '/favicon.ico', sizes: '48x48' },
  {
    rel: 'icon',
    href: '/icon-light.svg',
    type: 'image/svg+xml',
    media: '(prefers-color-scheme: light)'
  },
  {
    rel: 'icon',
    href: '/icon-dark.svg',
    type: 'image/svg+xml',
    media: '(prefers-color-scheme: dark)'
  }
];

これは、単一のSVGファイル内にインラインCSSを書くよりもはるかにクリーンであり、ブラウザが実際に必要なアセットのみをリクエストすることを保証します。

上級編:Loaderデータを使った動的Favicon

ここでよくある落とし穴があります。開発者がlinksエクスポートを使って未読バッジを表示しようとするケースです。何が問題なのでしょうか?LinksFunctionはuseLoaderData()にアクセスできません。コンポーネントがレンダリングされる前に評価されるからです。

ユーザーの状態(未読メッセージなど)に基づいて動的なfaviconが必要な場合は、その特定のタグについてのみlinksエクスポートをバイパスし、ルートコンポーネントの<head>内で標準のHTML要素を手動でレンダリングする必要があります。

export default function App() {
  const data = useLoaderData<typeof loader>();
  const faviconUrl = data.hasUnread ? '/icon-unread.svg' : '/icon.svg';

  return (
    <html lang='en'>
      <head>
        <Meta />
        <Links />
        {/* 動的faviconを手動でレンダリング */}
        <link rel='icon' href={faviconUrl} type='image/svg+xml' />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

このアプローチなら両方のメリットを享受できます。静的アセット(Apple Touch Iconなど)はクリーンなlinksエクスポートに保持しつつ、動的な状態はReactツリーで直接処理します。標準的なReactのfavicon設定から移行してきた場合、このパターンは非常に馴染みやすいはずです。

最終テスト

デプロイ後、ブラウザはキャッシュされたアイコンの更新に対して非常に頑固です。リロードしても古いRemixロゴが表示される場合は、ローカルキャッシュの問題である可能性が高いです。コードのデバッグに何時間も費やす前に、まずはfaviconのキャッシュクリアの方法を確認してください。

Remixはドキュメントのheadを明示的に管理することを強制しますが、結果としてバグが減り、パフォーマンスが向上します。SVG + ICOフォールバックのパターンを守り、静的アセットにlinksエクスポートを活用すれば、すべてのデバイスで完璧に機能するプロフェッショナルなブラウザタブを実現できるでしょう。