A small Tailwind CSS 4 setup for Next.js

The first Tailwind file in a new project should not require a tour of the build system. With Tailwind CSS 4 and the Next.js App Router, the default path is short: install the PostCSS adapter, import Tailwind once, and start writing classes.
This article follows the current v4 PostCSS integration. If you are maintaining
an older v3 project, the tailwind.config.js content globs and @tailwind
directives belong to that older setup; do not mix the two configurations by
accident.
Start with the generator
For a new application, let create-next-app make the first decisions:
pnpm create next-app@latest my-tailwind-app --yes
cd my-tailwind-app
pnpm devThe current Next.js installation guide says that --yes accepts the recommended
defaults, including TypeScript, ESLint, Tailwind CSS, the App Router, and
Turbopack. If the CLI's defaults have changed since you are reading this, answer
the prompts explicitly and keep the generated lockfile with the project.
If Tailwind is being added to an existing App Router project, install the same pieces yourself:
pnpm add -D tailwindcss @tailwindcss/postcss postcssCreate postcss.config.mjs in the project root:
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default configThen import Tailwind from the global stylesheet that the root layout loads:
/* app/globals.css */
@import 'tailwindcss';That is enough to use utilities. For an MDX site, I also install the official typography plugin and load it from the stylesheet:
pnpm add -D @tailwindcss/typography/* app/globals.css */
@import 'tailwindcss';
@plugin '@tailwindcss/typography';The plugin gives long-form HTML a prose API. It is optional; a small product
interface does not need it merely because Tailwind is installed.
Put fonts and global styles in the root layout
next/font can self-host the
fonts at build time. Using CSS variables keeps the font choices available to
Tailwind while allowing a normal fallback stack:
// app/layout.tsx
import type { Metadata } from 'next'
import { Inter, JetBrains_Mono } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-jetbrains-mono',
})
export const metadata: Metadata = {
title: 'My Tailwind app',
description: 'A small Next.js and Tailwind CSS app.',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${jetbrainsMono.variable}`}>
<body className="bg-zinc-950 font-sans text-zinc-100">{children}</body>
</html>
)
}Add the corresponding theme variables to app/globals.css:
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@theme inline {
--font-sans: var(--font-inter, ui-sans-serif), system-ui, sans-serif;
--font-mono:
var(--font-jetbrains-mono, ui-monospace), SFMono-Regular, Menlo, Monaco,
Consolas, 'Liberation Mono', 'Courier New', monospace;
}The @theme block is where a token becomes part of Tailwind's utility API. The
inline modifier is intentional here: both font tokens point at variables that
next/font puts on the document. Without inline, Tailwind can generate a
font-sans utility that refers to var(--font-sans), leaving CSS to resolve the
nested var(--font-inter) where the theme variable was declared. If the font
variable is defined deeper in the tree, that indirection can fall back instead
of reaching the intended font. @theme inline makes Tailwind emit the referenced
value directly in the utility. A literal token can use plain @theme; a regular
CSS variable is still the right choice for a value that should not create a
utility class.
Understand what gets scanned
Tailwind v4 scans source files for complete class-name candidates, so a basic
application usually does not need a content array or a generated
tailwind.config.ts. The class has to exist as a complete string in the source.
For a finite set of variants, map each variant to static classes:
const statusClasses = {
ready: 'bg-emerald-400/15 text-emerald-200',
waiting: 'bg-amber-400/15 text-amber-200',
failed: 'bg-rose-400/15 text-rose-200',
} as const
type Status = keyof typeof statusClasses
export function StatusBadge({ status }: { status: Status }) {
return (
<span className={`rounded-full px-3 py-1 text-sm ${statusClasses[status]}`}>
{status}
</span>
)
}bg-${color}-400 looks compact, but Tailwind cannot infer the possible values
from that interpolation. The map is a little more explicit and makes the
allowed states visible to TypeScript as well.
If the classes live in an ignored package or outside the stylesheet's normal
source base, register that path with @source. This is particularly useful in
a monorepo:
@import 'tailwindcss' source('../');
@source '../packages/ui';Use the path relative to the stylesheet, and add only the directories that actually contain Tailwind classes.
Build one page before building a system
// app/page.tsx
export default function HomePage() {
return (
<main className="mx-auto min-h-screen max-w-3xl px-6 py-16">
<div className="space-y-8">
<header className="space-y-3">
<p className="text-sm font-medium uppercase tracking-[0.18em] text-teal-300">
Build status
</p>
<h1 className="text-4xl font-semibold tracking-tight">
The first screen is ready
</h1>
<p className="max-w-xl text-lg leading-8 text-zinc-400">
A small App Router page is enough to prove that CSS, fonts, and
prose styles are connected before the product grows around them.
</p>
</header>
<section className="rounded-2xl border border-zinc-800 bg-zinc-900/70 p-6 shadow-2xl shadow-black/20">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-mono text-sm text-zinc-400">deploy/web</h2>
<p className="mt-2 text-xl font-medium">Ready for review</p>
</div>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-sm text-emerald-200">
ready
</span>
</div>
<p className="mt-6 text-sm leading-6 text-zinc-400">
Keep this page small. Add tokens when the same decision appears in
more than one real component, then give that decision a name.
</p>
</section>
<article className="prose prose-invert">
<h2>Why this is enough for the first pass</h2>
<p>
The root layout owns the global stylesheet and fonts. The page owns
its markup. Tailwind generates the utilities that appear in the
source, and the typography plugin handles this long-form section.
</p>
</article>
</div>
</main>
)
}Run pnpm dev, change one class, and check the browser before adding more
abstractions. That tiny loop catches a surprising number of setup mistakes.
The defaults I add after repetition appears
When a real component gives me a reason, I add the decision at the narrowest useful layer:
- one font stack for body text and one for code,
- semantic colors in
@themewhen they need Tailwind utilities, - regular
:rootvariables when they are only CSS values, - a
cnortwMergehelper for conditional classes, - explicit dimensions for repeated images, cards, and icon buttons.
The sizing decision is easy to postpone and annoying to recover later. A long
title can push a card below its neighbor; an image without an aspect ratio can
move the rest of a page while it loads. aspect-ratio, fixed icon dimensions,
minimum card heights, and a readable prose width are simple constraints that
make the interface easier to inspect.
The utilities are the vocabulary. The design decisions still belong to the application.