A headless theme provider for TanStack Start applications.
- Headless - bring your own themes, classes, and styles. No built-in themes.
- SSR-safe - themes resolve on the server via cookies. No flash of unstyled content.
- Typed - full type-safety for theme IDs and custom theme metadata.
- Tiny - zero runtime dependencies. Just
reactand@tanstack/react-routeras peer deps.
npm install @ajstrongdev/start-themes
# or
pnpm add @ajstrongdev/start-themes
# or
yarn add @ajstrongdev/start-themes
# or
bun install @ajstrongdev/start-themesPeer dependencies: react >=19, @tanstack/react-router >=1
Create a theme config file in your app. Add whatever properties you need — defineThemes preserves them with full type safety.
// src/lib/theme.ts
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { defineThemes } from "@ajstrongdev/start-themes";
export const themeConfig = defineThemes({
themes: [
{ id: "light", isDark: false, label: "Light Mode" },
{ id: "dark", isDark: true, label: "Dark Mode" },
{ id: "nord", isDark: true, label: "Nord" },
],
defaultTheme: "dark",
});
export type ThemeId = (typeof themeConfig)["themeIds"][number];Server functions must live in your app code so TanStack Start can process them.
// src/lib/theme.ts
export const getTheme = createServerFn().handler(() =>
themeConfig.resolveTheme(getCookie(themeConfig.cookieKey)),
);
export const setTheme = createServerFn()
.inputValidator(themeConfig.validateTheme)
.handler(({ data }) =>
setCookie(themeConfig.cookieKey, data, {
maxAge: themeConfig.cookieMaxAge,
}),
);// src/routes/__root.tsx
import { ThemeProvider } from "@ajstrongdev/start-themes";
import { themeConfig, getTheme, setTheme } from "@/lib/theme";
export const Route = createRootRouteWithContext()({
loader: () => getTheme(),
shellComponent: RootDocument,
component: RootLayout,
});
function RootLayout() {
const theme = Route.useLoaderData();
return (
<ThemeProvider theme={theme} onThemeChange={(id) => setTheme({ data: id })}>
<Outlet />
</ThemeProvider>
);
}
function RootDocument({ children }: { children: React.ReactNode }) {
const theme = Route.useLoaderData();
return (
<html className={themeConfig.getClasses(theme)}>
<head>
<HeadContent />
</head>
<body>{children}</body>
</html>
);
}import { useTheme } from "@ajstrongdev/start-themes";
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
Current: {theme}
</button>
);
}Creates a typed theme configuration object.
| Option | Type | Default | Description |
|---|---|---|---|
themes |
ThemeDefinition[] |
required | Array of theme definitions. Each must have an id string. Add any custom properties. |
defaultTheme |
string |
required | Theme ID to use when no cookie is set or the value is invalid. |
resolveClasses |
(theme) => string |
Auto | Custom function to generate CSS classes. Default: "<id>" for light, "<id> dark" for dark themes. |
cookieKey |
string |
"_theme" |
Cookie name for persistence. |
cookieMaxAge |
number |
31536000 |
Cookie max-age in seconds (default 1 year). |
| Property | Type | Description |
|---|---|---|
themes |
T |
The original themes array with full types preserved. |
defaultTheme |
string |
The default theme ID. |
themeIds |
readonly string[] |
Array of all valid theme IDs. |
cookieKey |
string |
The cookie key. |
cookieMaxAge |
number |
The cookie max-age. |
resolveTheme(cookieValue) |
string |
Resolve a cookie value to a valid theme ID. Falls back to defaultTheme. |
getClasses(themeId) |
string |
Generate CSS class string for the <html> element. |
validateTheme(value) |
string |
Validate input is a known theme ID. Throws on invalid input. Works as a server function input validator. |
React context provider for the current theme.
| Prop | Type | Description |
|---|---|---|
theme |
string |
Current theme ID (from loader data). |
onThemeChange |
(theme: string) => Promise<unknown> | unknown |
Called when the theme changes. The TanStack Router is automatically invalidated afterward. |
children |
ReactNode |
— |
Hook to access the current theme context. Must be called within a <ThemeProvider>.
Returns { theme: string; setTheme: (theme: string) => void }.
By default, getClasses returns the theme ID as a CSS class, plus "dark" for themes with isDark: true:
"light" → "light"
"dark" → "dark dark"
"dracula" → "dracula dark"
Override this with resolveClasses:
const themeConfig = defineThemes({
themes: [
{ id: "light", isDark: false },
{ id: "midnight", isDark: true },
],
defaultTheme: "midnight",
resolveClasses: (theme) => {
// Only output "dark" class, skip theme ID
return theme.isDark ? "dark" : "";
},
});Add any properties to your theme definitions. They're fully typed:
const themeConfig = defineThemes({
themes: [
{
id: "ocean",
isDark: true,
label: "Ocean Blue",
swatch: "#1e3a5f",
fontFamily: "Inter",
},
],
defaultTheme: "ocean",
});
// Fully typed access
themeConfig.themes[0].label; // "Ocean Blue"
themeConfig.themes[0].swatch; // "#1e3a5f"
themeConfig.themes[0].fontFamily; // "Inter"This package pairs well with Tailwind's dark: variant. In your CSS:
@custom-variant dark (&:is(.dark *));Then define your theme CSS variables using class selectors that match your theme IDs:
:root {
--background: white;
--foreground: black;
}
.dark {
--background: #1a1a2e;
--foreground: #eaeaea;
}
.nord {
--background: #2e3440;
--foreground: #d8dee9;
}