Modern Tailwind CSS and Next.js setup

The Tailwind setup I use now is boring, which is a compliment.
Older Next.js + Tailwind projects often needed Babel plugins, purge config, custom webpack fixes, or CSS-in-JS bridges. The current path is much cleaner: start with the App Router, keep styles global where they belong, and let Tailwind scan the files that actually contain UI.
I reach for this setup when I do not want the styling layer to become the project. It is not clever. It is just hard to break.
Start the project
pnpm create next-app@latest my-tailwind-app --typescript --app
cd my-tailwind-appI keep TypeScript on by default. It is easier to remove strictness later than to retrofit it after components have spread across the app.
Add Tailwind
pnpm add -D tailwindcss postcss autoprefixer
npx tailwindcss init -pUse content globs that match the App Router and shared components:
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./app/**/*.{ts,tsx,mdx}',
'./components/**/*.{ts,tsx,mdx}',
'./pages/**/*.{ts,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/typography')],
}
export default configThe typography plugin is worth adding if the site has MDX, docs, or long-form content. I add it early because prose styling is one of those tiny chores that otherwise turns into a pile of one-off classes.
Wire the stylesheet
Create app/globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
body {
@apply bg-black font-sans text-zinc-100;
}Then import it from the root layout and add fonts in one place:
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 mono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' })
export const metadata: Metadata = {
title: 'My Tailwind Starter',
description: 'A small Next.js and Tailwind starter.',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable}`}>
<body className="font-sans">{children}</body>
</html>
)
}Keep the first page simple
export default function HomePage() {
return (
<main className="mx-auto flex min-h-screen max-w-3xl flex-col gap-8 px-6 py-16">
<section className="space-y-3">
<h1 className="text-4xl font-semibold">
Modern Tailwind CSS with Next.js
</h1>
<p className="text-lg text-zinc-400">
A small starter with App Router, TypeScript, Tailwind, and typography
styles.
</p>
</section>
<article className="prose prose-invert">
<h2>Why this setup holds up</h2>
<p>
There is no custom Babel config, no CSS-in-JS bridge, and no hidden
webpack patch. Tailwind scans the files you edit, PostCSS handles the
transform, and the App Router keeps route code close to the UI.
</p>
</article>
</main>
)
}Enough for the first screen. I would rather add tokens after the product has repeated components than invent a design system before there is a product to design.
A few defaults I keep
I usually add the small decisions below before the first real screen lands:
- one font stack for body text and one for code,
- a small set of semantic colors in CSS variables,
- typography styles for MDX or CMS content,
- a
cnortwMergehelper for conditional classes, - strict image sizing for cards, avatars, logos, and hero media.
The last point matters more than it sounds. The messy Tailwind pages I regret were not messy because Tailwind failed. They were messy because images, cards, buttons, and nav items did not have stable dimensions. A hover state changed height. A long title pushed a sibling card down. An icon had a different optical size from the next one.
Tailwind is good at fixing that if the component author is explicit. Use
aspect-ratio for media, fixed square dimensions for icon buttons, min-height
for repeated cards, and constrained line lengths for prose. The utilities are
not the design system. They are the vocabulary you use to stop the page from
shifting under the user.