System Boot Sequence
_
Initializing... 0%
nextjsJuly 2, 20264 min read

How I Set Up Every Next.js + Tailwind Project

My opinionated project scaffold: folder structure, design tokens over Tailwind defaults, font loading, dark mode, and the config decisions I make before writing any feature code.

Every project I start follows the same setup. Not because I love boilerplate, but because consistent structure means I spend zero time thinking about where things go and all my time thinking about what to build.

Here is the template I use, and why each decision is made the way it is.

Folder structure

src/
├── app/                  # Next.js routes
│   ├── (marketing)/      # Route group: shared nav + footer
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── blog/
│   └── layout.tsx        # Root layout: fonts, providers, metadata
├── components/
│   ├── layout/           # Header, Footer, ThemeToggle
│   ├── ui/               # Buttons, Cards, Inputs
│   └── [feature]/        # Feature-specific components
├── config/               # Site config, nav items, metadata
├── data/                 # Page copy, separated from components
├── lib/                  # Utilities, helpers
└── styles/               # CSS entry point, tokens, animations

The key rule: components never contain marketing copy. All text lives in data/ files. Components receive content via props. This makes it easy to update copy without touching component logic.

Design tokens over Tailwind defaults

I do not use Tailwind's default colour palette. The first thing I do is define my own tokens:

:root {
  --brand-bg: #060810;
  --brand-text: #dce4f0;
  --brand-text-dim: #7a88a8;
  --brand-accent: oklch(0.72 0.20 240);
  --brand-line: #171d30;
  --brand-surface: #0b0e1a;
}

Components reference these tokens, not Tailwind classes for colour. color: var(--brand-text-dim) instead of text-gray-400. This means I can change the entire colour system by editing one file, and dark/light mode is just swapping token values.

Tailwind still handles spacing, layout utilities, and responsive breakpoints. I just do not let it own the visual identity.

Font loading

I load fonts through next/font/google in the root layout and assign them to CSS custom properties:

const sans = Fira_Sans({ subsets: ['latin'], variable: '--font-sans' })
const display = Space_Grotesk({ subsets: ['latin'], variable: '--font-display' })
const mono = Fira_Code({ subsets: ['latin'], variable: '--font-mono' })

Three fonts is my max. A sans for body, a display for headings, a mono for code and labels. More than three and the page starts to feel noisy.

Dark mode

I use next-themes with a custom attribute instead of a class:

<ThemeProvider attribute="data-theme" defaultTheme="dark" storageKey="theme">

CSS tokens swap based on the attribute:

:root, [data-theme="dark"] {
  --brand-bg: #060810;
  --brand-text: #dce4f0;
}
 
[data-theme="light"] {
  --brand-bg: #ffffff;
  --brand-text: #181c22;
}

Default is dark. I design dark-first and add light mode after. Going the other direction — light-first, then adding dark — always results in a dark mode that looks like an afterthought.

Component architecture

Every page follows three layers:

  1. page.tsx — fetches data, sets metadata, returns a single view component
  2. View — assembles section components in order, receives all data via props
  3. Components — small, focused pieces that receive data via props, no fetching
page.tsx → <HomepageView data={data} />
  └── HomepageView → <Hero /> + <Features /> + <CTA />
      ├── Hero → heading, subheading, CTA button
      ├── Features → list of feature cards
      └── CTA → final call to action

This sounds like over-engineering until your page.tsx hits 200 lines of inline JSX. The view layer prevents that.

Route groups for shared layouts

Routes that share the same navigation and footer go in a route group:

app/
├── (marketing)/        # Shared Nav + Footer
│   ├── layout.tsx      # Defines the shell once
│   ├── page.tsx        # Homepage
│   └── blog/page.tsx   # Blog
└── admin/              # Different layout, no shared nav
    ├── layout.tsx
    └── page.tsx

Never duplicate your Nav and Footer across multiple layouts. One shell, one place.

What I skip

  • ESLint plugins beyond the Next.js default — I add rules when I hit a real problem, not preemptively
  • Husky / lint-staged — I run lint and format in CI, not in git hooks. Git hooks that take 10 seconds break my flow.
  • Complex state management — React context handles most of it. I reach for Zustand if context gets unwieldy. I have never needed Redux in a Next.js project.
  • Testing setup upfront — I add Vitest when I have something worth testing, not as part of the initial scaffold. Empty test files are noise.

The whole point of a consistent setup is to remove decisions. Every project starts the same way, so I can start building features on day one instead of day three.

Keep reading
product
Building Developer Tools No One Asked For
open-source
Why I Open-Source My Tools
engineering
The Stack I Use and Why