Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions examples/with-cache-components/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# dependencies
node_modules

# next.js
.next
.turbo

# misc
.DS_Store
*.tsbuildinfo
next-env.d.ts
11 changes: 11 additions & 0 deletions examples/with-cache-components/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# next-themes with Cache Components

Demonstrates next-themes working with Next.js [Cache Components](https://nextjs.org/docs/app/guides/cache-components) (`cacheComponents: true`).

When Cache Components keeps multiple routes mounted in the DOM, theme providers that store state in React can go stale. The `useSyncExternalStore` approach in next-themes ensures all mounted routes share the same theme.

## Running

```bash
pnpm dev
```
74 changes: 74 additions & 0 deletions examples/with-cache-components/app/[locale]/event-log.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import { useEffect, useRef, useState } from "react";

export function EventLog() {
const [entries, setEntries] = useState<string[]>([]);
const scrollRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const html = document.documentElement;

function log(msg: string) {
const time = new Date().toLocaleTimeString("en", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
setEntries((prev) => [...prev.slice(-19), `${time} ${msg}`]);
}

log(`initial data-theme="${html.getAttribute("data-theme")}"`);

const observer = new MutationObserver(() => {
log(`data-theme changed to "${html.getAttribute("data-theme")}"`);
});

observer.observe(html, {
attributes: true,
attributeFilter: ["data-theme"],
});

return () => observer.disconnect();
}, []);

useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight);
}, [entries]);

return (
<div style={{ border: "1px solid var(--border)", borderRadius: "0.5rem" }}>
<div style={styles.header}>
Event log (watching data-theme attribute)
</div>
<div ref={scrollRef} style={styles.log}>
{entries.length === 0 ? (
<p style={{ color: "var(--muted)", opacity: 0.5 }}>
No events yet...
</p>
) : (
entries.map((entry, i) => <div key={i}>{entry}</div>)
)}
</div>
</div>
);
}

const styles = {
header: {
borderBottom: "1px solid var(--border)",
padding: "0.5rem 0.75rem",
fontSize: "0.75rem",
fontWeight: 500,
color: "var(--muted)",
},
log: {
height: "10rem",
overflowY: "auto" as const,
padding: "0.75rem",
fontFamily: "monospace",
fontSize: "0.75rem",
color: "var(--muted)",
},
} satisfies Record<string, React.CSSProperties>;
47 changes: 47 additions & 0 deletions examples/with-cache-components/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ThemeProvider } from "next-themes";

export function generateStaticParams() {
return [{ locale: "en" }, { locale: "es" }];
}

const labels: Record<string, string> = {
en: "English",
es: "Español",
};

export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;

return (
<ThemeProvider>
<div style={styles.container}>
<p style={styles.badge}>
Locale: <strong>{labels[locale] ?? locale}</strong>
</p>
{children}
</div>
</ThemeProvider>
);
}

const styles = {
container: {
maxWidth: "36rem",
margin: "0 auto",
padding: "2rem 1.5rem",
},
badge: {
marginBottom: "1.5rem",
background: "var(--surface)",
padding: "0.5rem 0.75rem",
borderRadius: "0.375rem",
fontFamily: "monospace",
fontSize: "0.75rem",
},
} satisfies Record<string, React.CSSProperties>;
81 changes: 81 additions & 0 deletions examples/with-cache-components/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { ThemeToggle } from "./theme-toggle";
import { EventLog } from "./event-log";

const content: Record<string, { title: string; description: string }> = {
en: {
title: "Hello",
description: "Toggle the theme, switch locale, and see what happens.",
},
es: {
title: "Hola",
description: "Cambia el tema, cambia de idioma, y mira lo que pasa.",
},
};

export default async function Page({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const { title, description } = content[locale] ?? content.en;

return (
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
<div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 600 }}>{title}</h1>
<p style={{ marginTop: "0.5rem", color: "var(--muted)" }}>
{description}
</p>
</div>

<ThemeToggle />

<div style={styles.card}>
<h2 style={styles.cardTitle}>How it works</h2>
<ol style={styles.list}>
<li>
Each locale (<code>/en</code>, <code>/es</code>) is a separate
route.
</li>
<li>
With <code>cacheComponents</code> enabled, navigating between
locales keeps both routes mounted in the DOM.
</li>
<li>
<code>next-themes</code> uses <code>useSyncExternalStore</code>{" "}
backed by <code>localStorage</code> so the theme stays consistent
across all mounted routes.
</li>
<li>
Toggle the theme, switch locale, and check the event log below.
</li>
</ol>
</div>

<EventLog />
</div>
);
}

const styles = {
card: {
border: "1px solid var(--border)",
borderRadius: "0.5rem",
padding: "1rem",
},
cardTitle: {
marginBottom: "0.5rem",
fontSize: "0.875rem",
fontWeight: 500,
color: "var(--muted)",
},
list: {
paddingLeft: "1.25rem",
fontSize: "0.875rem",
color: "var(--muted)",
display: "flex",
flexDirection: "column" as const,
gap: "0.25rem",
},
} satisfies Record<string, React.CSSProperties>;
32 changes: 32 additions & 0 deletions examples/with-cache-components/app/[locale]/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

import { useTheme } from "next-themes";

export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();

return (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span style={{ fontSize: "0.875rem", color: "var(--muted)" }}>
Current theme:
</span>
<button
onClick={() =>
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
style={{
borderRadius: "9999px",
border: "1px solid var(--border)",
padding: "0.375rem 1rem",
fontSize: "0.875rem",
fontWeight: 500,
background: "transparent",
color: "inherit",
cursor: "pointer",
}}
>
{resolvedTheme === "dark" ? "Dark" : "Light"} — click to toggle
</button>
</div>
);
}
39 changes: 39 additions & 0 deletions examples/with-cache-components/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}

:root {
--bg: #ffffff;
--fg: #171717;
--border: rgba(0, 0, 0, 0.1);
--muted: rgba(0, 0, 0, 0.5);
--surface: rgba(0, 0, 0, 0.04);
}

[data-theme="dark"] {
--bg: #0a0a0a;
--fg: #ededed;
--border: rgba(255, 255, 255, 0.1);
--muted: rgba(255, 255, 255, 0.5);
--surface: rgba(255, 255, 255, 0.06);
}

body {
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
}

a {
color: inherit;
text-decoration: none;
}

a:hover {
text-decoration: underline;
}
39 changes: 39 additions & 0 deletions examples/with-cache-components/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";

export const metadata: Metadata = {
title: "next-themes + Cache Components",
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<nav style={styles.nav}>
<span style={styles.title}>next-themes + Cache Components</span>
<Link href="/en">English</Link>
<Link href="/es">Español</Link>
</nav>
{children}
</body>
</html>
);
}

const styles = {
nav: {
display: "flex",
alignItems: "center",
gap: "1.5rem",
borderBottom: "1px solid var(--border)",
padding: "1rem 1.5rem",
},
title: {
fontWeight: 600,
},
} satisfies Record<string, React.CSSProperties>;
5 changes: 5 additions & 0 deletions examples/with-cache-components/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";

export default function Home() {
redirect("/en");
}
7 changes: 7 additions & 0 deletions examples/with-cache-components/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
cacheComponents: true,
};

export default nextConfig;
21 changes: 21 additions & 0 deletions examples/with-cache-components/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "with-cache-components",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^16",
"next-themes": "workspace:*",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5"
}
}
Loading
Loading