🎇

環境の判別に favicon を変更する @ Next.js

2025/01/22に公開

Rails の Web アプリの開発で昔、ステージング環境や本番環境、ローカル環境を簡単に判別するために各環境で favicon を変更する提案と実装をした事が有りました。
その時は確か、erb ファイル内に SVG を入れて環境ごとに色を変えて返すような実装をしたのですが、Next.js でもサクッと同じ事が出来ないか調べてみました。

少し回り道をして解決策に辿り着きましたが、結果としては公式ドキュメントにある Generate icons using code (.js, .ts, .tsx) を利用するのが簡単で最適な様です。

どう解決しようとしていたのかと、併せてドキュメントに沿った調整例を紹介します。

Route Handlers で実現する

Next.js (App Router) の挙動から、
試しに app 配下に favicon.sgv/page.tsx を用意して、内部に React Icons でアイコンを配してみました。

/app/favicon.svg/page.tsx
import { SiNextdotjs } from "react-icons/si";

export default async function Page() {
	return <SiNextdotjs color="red" />;
}

この状態で http://localhost:3000/favicon.svg にアクセスすると SVG が表示されます。
ただ、HTML の body 内に SVG がされたページになってしまって、その URL を Layout で読み込ませるなどしてみても、当然ながら favicon には反映されずでした。

調べつつ試してみたところ、Route Handlers を利用する形で route.tsx にリネーム、React の API (renderToString) を併用する事で SVG として扱える様になりました。

/app/favicon.svg/route.tsx
import { SiNextdotjs } from "react-icons/si";

const Icon = () => <SiNextdotjs color="red" />;

export async function GET() {
	const body = (await import("react-dom/server")).renderToString(<Icon />);
	const init = { headers: { "Content-Type": "image/svg+xml" } };

	return new Response(body, init);
}

💡 NOTE: Content-Typeapplication/svg+xml にした場合は閲覧時にダウンロードされる様になり、favicon としても利用できませんでした。

あとは SVG を Layout ファイルで読み込めば完了です。

/app/layout.tsx
interface Props {
	children: React.ReactNode;
}
export default function RootLayout({ children }: Props) {
	return (
		<html lang="ja">
			{/* NOTE: Safari は SVG ファビコン非対応 */}
			<link rel="icon" href="/favicon.svg" />
			<body>{children}</body>
		</html>
	);
}

環境の判別

上記に加えて環境ごとに判別用の処理と色を設定すれば完了です。

Vercel を利用している場合に限られますが、
git のブランチがデプロイされた URL は以下の様なルールになっています。
ref: Generated from Git

<project-name>-git-<branch-name>-<scope-slug>.vercel.app

ローカル環境は localhost なので、request の URL を引っ掛けて色を変更する事にしました。
以下に実装例のコードを掲載しておきます。

Utilities
/app/lib/utils.ts
/** URL・ホストからデプロイされたブランチか判定する正規表現
 *
 * ```txt
 * <project-name>-git-<branch-name>-<scope-slug>.vercel.app
 * ```
 *
 * @see {@link https://vercel.com/docs/deployments/generated-urls#generated-from-git|Generated from Git} | {@link https://vercel.com/docs/deployments/generated-urls|Accessing Deployments through Generated URLs}
 */
const deployedBranchUrlRe = new RegExp(
	`${process.env.VERCEL_PROJECT_NAME}-git-[\\w-\\.]+\\.vercel\\.app`,
);
/** URL・ホストからデプロイされたブランチか判定 */
export const isDeployedBranch = (urlOrHost: string) =>
	deployedBranchUrlRe.test(urlOrHost);

/** ローカルホストか判定 */
export const isLocalhost = (urlOrHost: string) => /localhost/.test(urlOrHost);
Layout
/app/layout.tsx
import { headers } from "next/headers";
import { isDeployedBranch, isLocalhost } from "./lib/utils";

interface Props {
	children: React.ReactNode;
}
export default async function RootLayout({ children }: Props) {
	const host = String((await headers()).get("host"));
	const showFavicon = isDeployedBranch(host) || isLocalhost(host);

	return (
		<html lang="ja">
			{showFavicon && (
				// NOTE: Safari は SVG ファビコン非対応
				<link rel="icon" href="/favicon.svg" />
			)}
			<body>{children}</body>
		</html>
	);
}

(production の favicon に関しては、.ico ファイルを app 配下に置いておくと自動でそれが表示されるため、SVG は不要な前提で上記の様な実装にしていました。)

SVG
/app/favicon.svg/route.tsx
import { SiNextdotjs } from "react-icons/si";
import { isDeployedBranch, isLocalhost } from "../lib/utils";

const color = {
	localhost: "green",
	deployedBranch: "goldenrod",
} as const;
const environmentColor = (url: string) => {
	if (isDeployedBranch(url)) return color.deployedBranch;
	if (isLocalhost(url)) return color.localhost;
};
const Icon = SiNextdotjs;

export async function GET(request: Request) {
	const color = environmentColor(request.url);
	const iconProps = { color };

	const ReactDOMServer = await import("react-dom/server");
	const body = ReactDOMServer.renderToString(<Icon {...iconProps} />);

	const init = { headers: { "Content-Type": "image/svg+xml" } };

	return new Response(body, init);
}

簡単な動作確認に React Icons を使用していますが、実際には SVG ファイルを route.tsx に貼り付けるか import して使う感じになるかなと思います。
SVG を import する場合は追加設定が必要みたいなので、favicon だけで良ければファイル内か別途コンポーネント用の tsx を切って内部に貼り付けるで良さそう🤓

ドキュメントに沿った実装例

冒頭に記載の Generate icons using code (.js, .ts, .tsx) をベースに実装します。

Layout での読み込みは不要で以下のファイルを設置するだけで完了です。
(utils は上記の Utilities のコードをインポートしています)

Icon
/app/icon.tsx
// ref: https://nextjs.org/docs/app/api-reference/file-conventions/metadata/app-icons#generate-icons-using-code-js-ts-tsx
import { headers } from "next/headers";
import { ImageResponse } from "next/og";
import { SiNextdotjs } from "react-icons/si";
import { isDeployedBranch, isLocalhost } from "./lib/utils";

const IMAGE_SIZE = 32;

// Image metadata
export const size = {
	width: IMAGE_SIZE,
	height: IMAGE_SIZE,
};
export const contentType = "image/png";

const color = {
	localhost: "green",
	deployedBranch: "goldenrod",
} as const;
const environmentColor = (url: string) => {
	if (isDeployedBranch(url)) return color.deployedBranch;
	if (isLocalhost(url)) return color.localhost;
};

// Image generation
export default async function Icon() {
	const host = String((await headers()).get("host"));

	const Icon = SiNextdotjs;
	const iconProps = { size: IMAGE_SIZE, color: environmentColor(host) };

	return new ImageResponse(
		// ImageResponse JSX element
		<Icon {...iconProps} />,
		// ImageResponse options
		{
			// For convenience, we can re-use the exported icons size metadata
			// config to also set the ImageResponse's width and height.
			...size,
		},
	);
}

ちなみに、先述の favicon.svg/route.tsxicon.tsx を併用すると、Chrome は前者を、Safari は後者を表示しました。
結論、SVG なら内部に style 要素を設置すればダークモードにも対応できるので、ベースの favicon は上記の Route Handlers で実装しつつ、Safari 用に icon.tsx ファイルか favicon.ico を用意するのも有りかなと思いました。

[応用] クエリで色変え w/ Route Handlers

先に挙げた Route Handlers の例に派生して、
特定のクエリが付いている時だけアイコンのカラーを変える、で有ったり、
生成画像をダウンロードできる様にする (Content-Typeapplication/svg+xml) と言うのも簡単そうに思ったのでメモ✍️

Layout 上でクエリ値を取得する場合、ミドルウェアを弄るかクライアントコンポーネントを利用するしか方法が無さそうでした。
Favicon の読み込み自体をクライアントコンポーネント化してみます。
<link rel="icon" href="/favicon.svg" /><Favicon />

追加で以下の様な処理を入れたら何となく実現できた気がします。

  1. favicon-color のクエリが付いていたら favicon に色のクエリを付ける
  2. カラーリストに該当する色が存在する場合だけ色を変える
Favicon component & SVG
/app/ui/Favicon.tsx
"use client";

import { useSearchParams } from "next/navigation";

export function Favicon() {
	const color = useSearchParams().get("favicon-color");

	return (
		<link rel="icon" href={`/favicon.svg${color ? `?color=${color}` : ""}`} />
	);
}
/app/favicon.svg/route.tsx
const color = {
	localhost: "green",
	deployedBranch: "goldenrod",
} as const;

+ const paramColors = ["red", "green", "blue", "orange"] as const;
+ type ParamColor = (typeof paramColors)[number];

const environmentColor = (url: string) => {
+ 	const { searchParams } = new URL(url);
+ 
+ 	if (searchParams) {
+ 		const paramColor = searchParams.get("color");
+ 		const isColorValid = paramColors.some((item) => item === paramColor);
+ 
+ 		if (isColorValid) return paramColor as ParamColor;
+ 	}
+ 
	if (isDeployedBranch(url)) return color.deployedBranch;
	if (isLocalhost(url)) return color.localhost;
};
const Icon = SiNextdotjs;

ここまでで思ったより時間が掛かったので、これ以上は何か実用しようと思った際にもう少し深掘りしてみようと思います。
不備など何か有りましたらコメント等で気軽に突ついてみてくださいませ🤓

Discussion