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:
page.tsx— fetches data, sets metadata, returns a single view component- View — assembles section components in order, receives all data via props
- 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.