diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json
index 82f5c5c3e..f4358d193 100644
--- a/apps/dashboard/package.json
+++ b/apps/dashboard/package.json
@@ -83,7 +83,6 @@
"jose": "^6.1.3",
"lodash": "^4.17.21",
"next": "16.1.5",
- "next-themes": "^0.2.1",
"posthog-js": "^1.336.1",
"react": "19.2.3",
"react-day-picker": "^9.6.7",
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx
index 2653104cb..e8c715dfd 100644
--- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/webhooks/page-client.tsx
@@ -17,7 +17,7 @@ import { PageLayout } from "../page-layout";
import { useAdminApp } from "../use-admin-app";
import { getSvixResult } from "./utils";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
-import { useTheme } from "next-themes";
+import { useTheme } from "@/lib/theme";
import { AppPortal } from "svix-react";
import "svix-react/style.css";
diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx
index 5f579fe9d..fffdb7127 100644
--- a/apps/dashboard/src/app/layout.tsx
+++ b/apps/dashboard/src/app/layout.tsx
@@ -2,7 +2,6 @@ import { DevErrorNotifier } from '@/components/dev-error-notifier';
import { RouterProvider } from '@/components/router';
import { SiteLoadingIndicatorDisplay } from '@/components/site-loading-indicator';
import { StyleLink } from '@/components/style-link';
-import { ThemeProvider } from '@/components/theme-provider';
import { Toaster, cn } from '@/components/ui';
import { getPublicEnvVar } from '@/lib/env';
import { stackServerApp } from '@/stack';
@@ -88,6 +87,9 @@ export default function RootLayout({
...attributes,
});
})}
+
+ {/* eslint-disable-next-line @next/next/no-sync-scripts */}
+
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
+
+
+
+
+
+
+
+ {children}
+
+
+
+
diff --git a/apps/dashboard/src/components/payments/stripe-connect-provider.tsx b/apps/dashboard/src/components/payments/stripe-connect-provider.tsx
index da2155f64..eb1012737 100644
--- a/apps/dashboard/src/components/payments/stripe-connect-provider.tsx
+++ b/apps/dashboard/src/components/payments/stripe-connect-provider.tsx
@@ -8,7 +8,7 @@ import { loadConnectAndInitialize } from "@stripe/connect-js";
import {
ConnectComponentsProvider,
} from "@stripe/react-connect-js";
-import { useTheme } from "next-themes";
+import { useTheme } from "@/lib/theme";
import { useEffect } from "react";
import { appearanceVariablesForTheme } from "./stripe-theme-variables";
diff --git a/apps/dashboard/src/components/payments/stripe-elements-provider.tsx b/apps/dashboard/src/components/payments/stripe-elements-provider.tsx
index c1799fcb0..16de0ca2d 100644
--- a/apps/dashboard/src/components/payments/stripe-elements-provider.tsx
+++ b/apps/dashboard/src/components/payments/stripe-elements-provider.tsx
@@ -3,7 +3,7 @@ import { getPublicEnvVar } from "@/lib/env";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
-import { useTheme } from "next-themes";
+import { useTheme } from "@/lib/theme";
import { useMemo } from "react";
import { appearanceVariablesForTheme } from "./stripe-theme-variables";
diff --git a/apps/dashboard/src/components/theme-provider.tsx b/apps/dashboard/src/components/theme-provider.tsx
deleted file mode 100644
index f15aff5d3..000000000
--- a/apps/dashboard/src/components/theme-provider.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-'use client';
-
-import { ThemeProvider as NextThemeProvider } from "next-themes";
-
-export function ThemeProvider(props: { children: React.ReactNode }) {
- return (
-
- {props.children}
-
- );
-}
diff --git a/apps/dashboard/src/components/theme-toggle.tsx b/apps/dashboard/src/components/theme-toggle.tsx
index 7b9ec2755..8b88ca13b 100644
--- a/apps/dashboard/src/components/theme-toggle.tsx
+++ b/apps/dashboard/src/components/theme-toggle.tsx
@@ -1,7 +1,7 @@
import { Button } from "@/components/ui";
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
-import { useTheme } from "next-themes";
+import { useTheme } from "@/lib/theme";
type ViewTransitionWithReady = {
ready: Promise,
@@ -14,21 +14,11 @@ type DocumentWithViewTransition = globalThis.Document & {
const TRANSITION_DURATION_MS = 600;
export default function ThemeToggle() {
- const { resolvedTheme, setTheme } = useTheme();
- const isReady = resolvedTheme === "dark" || resolvedTheme === "light";
+ const { resolvedTheme, setTheme, mounted } = useTheme();
const handleToggle = () => {
- if (!isReady) {
- return;
- }
-
const nextTheme = resolvedTheme === "dark" ? "light" : "dark";
- if (typeof document === "undefined") {
- setTheme(nextTheme);
- return;
- }
-
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const documentWithTransition: DocumentWithViewTransition = document;
@@ -90,7 +80,7 @@ export default function ThemeToggle() {
size="icon"
className="w-8 h-8 hover:bg-muted/50"
onClick={handleToggle}
- disabled={!isReady}
+ disabled={!mounted}
aria-label="Toggle theme"
>
diff --git a/apps/dashboard/src/components/vibe-coding/code-editor.tsx b/apps/dashboard/src/components/vibe-coding/code-editor.tsx
index dbb0e81c5..6d45fb67b 100644
--- a/apps/dashboard/src/components/vibe-coding/code-editor.tsx
+++ b/apps/dashboard/src/components/vibe-coding/code-editor.tsx
@@ -1,7 +1,7 @@
import Editor, { Monaco } from '@monaco-editor/react';
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
import { deindent } from '@stackframe/stack-shared/dist/utils/strings';
-import { useTheme } from 'next-themes';
+import { useTheme } from '@/lib/theme';
import { dtsBundles } from './dts';
import { useState, useCallback } from 'react';
diff --git a/apps/dashboard/src/lib/theme.tsx b/apps/dashboard/src/lib/theme.tsx
index 1ff587139..0ed2f8b7a 100644
--- a/apps/dashboard/src/lib/theme.tsx
+++ b/apps/dashboard/src/lib/theme.tsx
@@ -1,45 +1,136 @@
-import { useEffect, useState } from "react";
+'use client';
+
+import { useSyncExternalStore } from 'react';
+
+type Theme = 'light' | 'dark' | 'system';
+type ResolvedTheme = 'light' | 'dark';
+
+const STORAGE_KEY = 'theme';
+
+// --- Theme preference store (module-level singleton) ---
+
+let currentTheme: Theme = 'system';
+
+if (typeof window !== 'undefined') {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored === 'dark' || stored === 'light' || stored === 'system') {
+ currentTheme = stored;
+ }
+ } catch { /* localStorage unavailable (eg. private browsing) */ }
+}
+
+const themeListeners = new Set<() => void>();
+
+function notifyThemeListeners() {
+ for (const listener of themeListeners) listener();
+}
+
+function subscribeTheme(listener: () => void): () => void {
+ themeListeners.add(listener);
+ return () => {
+ themeListeners.delete(listener);
+ };
+}
+
+function getThemeSnapshot(): Theme {
+ return currentTheme;
+}
+
+function getThemeServerSnapshot(): Theme {
+ return 'system';
+}
+
+// --- System theme preference (media query) ---
+
+const darkMq = typeof window !== 'undefined'
+ ? window.matchMedia('(prefers-color-scheme: dark)')
+ : null;
+
+const systemListeners = new Set<() => void>();
+
+if (darkMq) {
+ darkMq.addEventListener('change', () => {
+ if (currentTheme === 'system') {
+ applyThemeToDOM(darkMq.matches ? 'dark' : 'light');
+ }
+ for (const listener of systemListeners) listener();
+ });
+}
+
+function subscribeSystem(listener: () => void): () => void {
+ systemListeners.add(listener);
+ return () => {
+ systemListeners.delete(listener);
+ };
+}
+
+function getSystemSnapshot(): ResolvedTheme {
+ return darkMq?.matches ? 'dark' : 'light';
+}
+
+function getSystemServerSnapshot(): ResolvedTheme {
+ return 'light';
+}
+
+// --- Mounted (server=false, client=true; handled via useSyncExternalStore hydration) ---
+
+function subscribeMounted(): () => void {
+ return () => {};
+}
+function getMountedClient(): boolean {
+ return true;
+}
+function getMountedServer(): boolean {
+ return false;
+}
+
+// --- DOM helpers ---
+
+function applyThemeToDOM(resolved: ResolvedTheme) {
+ if (typeof document === 'undefined') return;
+ const root = document.documentElement;
+ root.classList.remove('light', 'dark');
+ root.classList.add(resolved);
+ root.style.colorScheme = resolved;
+}
+
+function resolve(theme: Theme, system: ResolvedTheme): ResolvedTheme {
+ return theme === 'system' ? system : theme;
+}
+
+// --- Public API ---
+
+export function setTheme(theme: Theme) {
+ currentTheme = theme;
+ try {
+ localStorage.setItem(STORAGE_KEY, theme);
+ } catch { /* localStorage unavailable */ }
+ applyThemeToDOM(resolve(theme, getSystemSnapshot()));
+ notifyThemeListeners();
+}
/**
- * Returns the current theme of the page.
+ * Hook that provides the current theme, resolved theme, and a setter.
*
- * @returns the current theme and a boolean indicating if the theme has been mounted
- *
- * Note that this doesn't work in the server component. Please use the css variables for server side rendering.
+ * Uses `useSyncExternalStore` to subscribe to both the user's preference
+ * (persisted in localStorage) and the system `prefers-color-scheme` query.
+ * No `useEffect` required.
+ */
+export function useTheme() {
+ const theme = useSyncExternalStore(subscribeTheme, getThemeSnapshot, getThemeServerSnapshot);
+ const systemTheme = useSyncExternalStore(subscribeSystem, getSystemSnapshot, getSystemServerSnapshot);
+ const resolvedTheme = resolve(theme, systemTheme);
+ const mounted = useSyncExternalStore(subscribeMounted, getMountedClient, getMountedServer);
+
+ return { theme, resolvedTheme, systemTheme, setTheme, mounted };
+}
+
+/**
+ * Convenience hook that returns just the resolved theme and mounted state.
+ * Useful for components that only need to read the current theme.
*/
export function useThemeWatcher() {
- const [mounted, setMounted] = useState(false);
- const [theme, setTheme] = useState<'dark' | 'light'>('light');
-
- useEffect(() => {
- setMounted(true);
-
- const updateTheme = () => {
- const themeEl = document.getElementById('--stack-theme-mode');
- setTheme(themeEl?.getAttribute('data-stack-theme') === 'dark' ? 'dark' : 'light');
- };
-
- // Watch for theme changes
- const observer = new MutationObserver((mutations) => {
- for (const mutation of mutations) {
- if (mutation.type === 'attributes' && mutation.attributeName === 'data-stack-theme') {
- updateTheme();
- break;
- }
- }
- });
-
- const themeEl = document.getElementById('--stack-theme-mode');
- if (themeEl) {
- updateTheme();
- observer.observe(themeEl, {
- attributes: true,
- attributeFilter: ['data-stack-theme']
- });
- }
-
- return () => observer.disconnect();
- }, []);
-
- return { mounted, theme };
+ const { resolvedTheme, mounted } = useTheme();
+ return { theme: resolvedTheme, mounted };
}