方針
Cookie を使えばリクエストのときに送信されるため SSR ができる
コード
layout.tsx
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// サーバーで Cookie から取得
const theme = await getThemeFromCookie();
return (
<html lang="ja" suppressHydrationWarning>
<head />
<body className={`antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme={theme}
enableSystem
disableTransitionOnChange
>
{children}
{/* Cookie が消されたとき用のコンポーネント */}
<ColorThemeConflictFix cookieTheme={theme} />
</ThemeProvider>
</body>
</html>
);
}
Cookie がユーザーの操作で消されることもあるのでそれを強制的に直します
lib/color-mode/color-theme-conflict-fix.tsx
"use client";
import { useTheme } from "next-themes";
import { useLayoutEffect, useRef } from "react";
import { Theme } from "./common";
export default function ColorThemeConflictFix({
cookieTheme,
}: {
cookieTheme: Theme;
}) {
const firstrendering = useRef(true);
const { theme, setTheme } = useTheme();
useLayoutEffect(() => {
if (firstrendering.current) {
if (theme != cookieTheme) {
setTheme(cookieTheme);
}
firstrendering.current = false;
}
}, [cookieTheme, theme, setTheme]);
return null;
}
lib/color-mode/common.ts
export const COLOR_THEME_COOKIE_KEY = "color-theme";
export type Theme = "light" | "dark" | "system";
export const THEMES = ["system", "light", "dark"] satisfies Theme[];
lib/color-mode/cookie-server
import "server-only";
import { cookies } from "next/headers";
import { COLOR_THEME_COOKIE_KEY, Theme, THEMES } from "./common";
export async function getThemeFromCookie(): Promise<Theme> {
const cookieStore = await cookies();
const savedCookie = cookieStore.get(COLOR_THEME_COOKIE_KEY)?.value;
if (!savedCookie) {
return "system";
}
if ((THEMES as string[]).includes(savedCookie)) {
return savedCookie as Theme;
} else {
return "system";
}
}
/lib/color-mode/cookie-client.ts
import { COLOR_THEME_COOKIE_KEY, Theme } from "./common";
export function setThemeCookie(theme: Theme) {
const maxAge = 60 * 60 * 24 * 365;
document.cookie = `${COLOR_THEME_COOKIE_KEY}=${encodeURIComponent(theme)}; Path=/; Max-Age=${maxAge}; SameSite=Lax`;
}
そしたら切り替え用のコンポーネントを作ります
/lib/color-mode/toggle-color-mode.tsx
export default async function ColorModeToggle() {
const theme = await getThemeFromCookie();
return <ColorModeToggleClient initMode={theme} />;
}
/lib/color-mode/toggle-color-mode-client.tsx
"use client";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { LaptopMinimal, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { THEMES, type Theme } from "./common";
import { setThemeCookie } from "./cookie-client";
function getNextTheme(currentTheme: Theme): Theme | null {
const currentIndex = THEMES.indexOf(currentTheme);
if (currentIndex < 0) {
return null;
}
let newIndex = currentIndex + 1;
if (newIndex >= THEMES.length) {
newIndex = 0;
}
return THEMES[newIndex];
}
export default function ColorModeToggleClient({
initMode,
}: {
initMode: Theme;
}) {
const { theme, setTheme } = useTheme();
const computedTheme = (theme ?? initMode) as Theme;
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => {
const nextTheme = getNextTheme(computedTheme);
if (!nextTheme) return;
setThemeCookie(nextTheme);
setTheme(nextTheme);
}}
>
{computedTheme === "light" && (
<Sun className="h-[1.2rem] w-[1.2rem]" />
)}
{computedTheme === "dark" && (
<Moon className="h-[1.2rem] w-[1.2rem]" />
)}
{computedTheme === "system" && (
<LaptopMinimal className="h-[1.2rem] w-[1.2rem]" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Toggle Mode</p>
</TooltipContent>
</Tooltip>
);
}
これでハイドレーションエラーはおきません (あとから Cookie 削除されない限りは)
あとがき
適当に作ったのでもし使う場合にはお好みで整えてください
あとページまるごとのキャッシュは基本無理になるので気をつけてください