A small Tailwind CSS 4 setup for Next.js

A small Tailwind CSS 4 setup for Next.js cover image

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 dev

The 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 postcss

Create postcss.config.mjs in the project root:

const config = {
  plugins: {
    '@tailwindcss/postcss': {},
  },
}
 
export default config

Then 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 @theme when they need Tailwind utilities,
  • regular :root variables when they are only CSS values,
  • a cn or twMerge helper 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.

References