Customizing the Theme
How to customize the theme in a Next.js app: change brand colors, corner radius, fonts, and dark mode through the Tailwind CSS v4 variables in globals.css.
The whole look of the app comes from a set of CSS variables in app/globals.css. Tailwind CSS v4 turns each variable into a utility class, and every shadcn/ui component is built from those classes, so changing --primary restyles every button and badge that reads it at once. A color rebrand happens in one file instead of across components/ui/. Fonts and the mobile browser-chrome color live elsewhere, covered below.
How globals.css is structured
The file has four blocks you care about:
| Block | What it does |
|---|---|
@theme inline | Connects each CSS variable to a Tailwind token, e.g. --color-primary: var(--primary). It also defines the --radius-* size scale, --font-sans, and a few fixed layout values (--radius-card, --max-width-8xl, --breakpoint-3xl, --spacing-section). Edit it to add a brand-new token, or to change one of the fixed values it sets directly. |
:root | The values used in light mode, one line per variable. |
.dark | The values used in dark mode. These override :root whenever the dark class is on the <html> tag. |
@layer components | Hand-written classes the utilities don't cover: the marketing type scale, the .hero-band gradient behind PageHero (a fixed near-black in both modes), and the .legal-prose rules that style the privacy and terms pages. |
Tailwind CSS v4's @custom-variant dark (&:is(.dark *)) tells dark: utility classes what to respond to. It points them at the same dark class the .dark block keys off, so the two turn on together whenever ThemeProvider sets that class on <html>.
Colors use the oklch(lightness chroma hue) form: lightness from 0 to 1, then how saturated it is, then the angle on the color wheel. Pick values visually at oklch.com and paste them in.
Change brand colors
Change a color in both :root and .dark, or it will only update in one mode. --primary is your main brand color. The default button fills with a diagonal gradient running from --primary to --primary-hover, so change the pair together or your new brand color ends up fading into the old one:
:root {
--primary: oklch(0.5168 0.166 254.97); /* blue */
--primary-hover: oklch(0.4629 0.1486 255.12); /* the gradient's far end */
--primary-foreground: oklch(1 0 0); /* text on primary */
}
.dark {
/* all three again. The shipped dark blues are pitched lighter so they
hold up against a near-black background. */
}--sidebar-accent is set to the same blue as --primary in both modes, but it's a separate token. If you change --primary, update --sidebar-accent to match, otherwise the active sidebar item keeps the old color.
Most colors come in a pair: the base color and a *-foreground for text or icons placed on top of it. On a default Badge, --primary is the fill and --primary-foreground is the label text. Keep enough contrast between the two so text stays readable.
:root is the full list, and the shadcn names in it (--background, --card, --popover, --muted, --accent, --destructive) do what you'd expect. Less obvious are the ones this template adds on top:
--primary-hover: the far end of the button gradient--border-strong: the heavier edge on outline and secondary buttons--dim-foreground: text one step quieter than--muted-foreground--link,--accent-blue,--star: link text, a lighter blue for accents, and the rating star fill--charcoal,--success,--warning: each with a matching*-foreground
Two tokens differ between the blocks in a way that catches people mid-rebrand. --secondary is the orange, and it's lightened in .dark so it still reads against a near-black background. --link matches --primary in light mode but switches to the lighter --accent-blue in dark. Want either one identical everywhere? Paste the same value into both blocks.
Change the corner roundness
One variable controls most of the corner rounding in the app. @theme inline calculates the sm / md / lg / xl steps from it, so you only edit this one line:
:root {
--radius: 0.625rem; /* about 10px. Lower for sharper corners, higher for rounder */
}One exception: --radius-card: 20px sits in @theme inline as a fixed value, not derived from --radius. It backs the rounded-card class that Card uses, so change it there if you want flatter or rounder cards.
Change fonts
The default font is Inter, loaded in app/layout.tsx with Next.js's next/font/google helper. It's exposed as the CSS variable --font-inter, applied on the <html> tag, and @theme inline maps that variable to --font-sans.
To use a different Google font, change the import and the function call. Keep the variable name --font-inter so the existing mapping still works:
import { Geist } from "next/font/google";
const font = Geist({
subsets: ["latin"],
display: "swap",
variable: "--font-inter", // keep this name; @theme inline points at it
});Then make sure <html> still gets className={font.variable}.
For a font file you host yourself, use next/font/local with the same variable. To add a separate heading font, register a new token in @theme inline (for example --font-heading: var(--font-cal-sans), sans-serif), load that font in layout.tsx, and apply it in markup with the font-heading class.
Dark mode
ThemeProvider (components/theme-provider.tsx) handles dark mode. It's a thin wrapper around the next-themes library, mounted once in app/layout.tsx:
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>What these options do:
attribute="class"adds or removes thedarkclass on<html>, which is what turns the.darkcolor block on or off.defaultTheme="system"withenableSystemfollows the visitor's operating-system setting until they pick a theme.disableTransitionOnChangeskips the color fade when switching, so the page doesn't flash.
The provider does one extra thing on top of next-themes. A small child component watches resolvedTheme and writes a theme-color meta tag from APP_CONFIG.theme.colors.background in lib/config.ts, so the browser chrome on mobile follows the theme the visitor picked instead of their OS setting.
ThemeToggle (components/theme-toggle.tsx) is the visible switch, calling setTheme("light") or setTheme("dark"). Its variant prop takes "dropdown", "overlay", or "footer", which adjusts the wrapper and padding to suit a dropdown menu, the mobile nav overlay, or the footer.
To stop following the operating-system setting and start new visitors on the same theme, turn enableSystem off (it defaults to on, so leaving it out isn't enough) and set a fixed default:
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>That only changes the starting point. A visitor who already picked a theme keeps it, and everyone can still switch. If you want one theme and no choice, pass forcedTheme="light" to the provider and pull ThemeToggle out of the four places that mount it: header.tsx, mobile-nav.tsx, footer.tsx, and dashboard/sidebar.tsx.
Why a rebrand never touches a component
shadcn/ui components use the token classes rather than hard-coded colors, give or take a few one-offs like the text-white on the destructive button. Card is bg-card rounded-card, Input is border-input, the default Button is from-primary to-primary-hover text-primary-foreground. Change the matching variable in globals.css and all of them restyle. To see which tokens a component reads, open its source in components/ui/.
Add a new design token
To add your own color, define it in three places: @theme inline, :root, and .dark. That's the same recipe --success and --warning follow in the template:
@theme inline {
--color-info: var(--info);
--color-info-foreground: var(--info-foreground);
}
:root {
--info: oklch(0.55 0.18 230);
--info-foreground: oklch(0.985 0 0);
}
.dark {
--info: oklch(0.6 0.18 230);
--info-foreground: oklch(0.985 0 0);
}You can now use bg-info and text-info-foreground anywhere in your markup, and they'll respond to light and dark mode like every built-in token.
Adding a New Page
How to add a new page in Next.js with the App Router. Where the file goes, how layouts and auth apply, and how to set page metadata and navigation links.
Authentication
How Next Starter handles authentication with Better Auth. Email/password and Google sign-in, email OTP verification, password resets, roles, and sessions.