Scaffold and maintain a clean Next.js (App Router) project with the team's conventions: a strict 3-layer CSS architecture (design tokens + base reset + CSS Modules) processed by PostCSS (postcss-pxtorem, custom-media, preset-env), a flat feature-grouped components folder where each component owns a co-located .module.css, and next-intl v4 internationalization with a [locale] segment. Use this when starting a NEW Next.js project, adding a component, writing CSS, or wiring up i18n. NO Tailwind, NO CMS/Payload.
Resources
10Install
npx skillscat add marvinvrdoljak/twosoles Install via the SkillsCat registry.
Next.js App-Router Project Setup
This skill encodes the team's house style for a plain Next.js App-Router
project (React 19, Next 15+) deployed on Vercel, with no CMS and no
Tailwind. Pages are mostly statically generated (SSG), but the full Next.js
runtime is available — middleware runs normally.
The core of this skill is the frontend: a CSS architecture you can style
freely and predictably (§1). The components folder (§2) and i18n (§3) support
that.
Ready-to-copy template files live in templates/ next to this file. When
scaffolding a fresh project, copy them into the project root and adjust the
tokens/messages. When working in an existing project, follow the rules below.
Golden rules (TL;DR)
Act on these first; the sections below are the detail.
- TypeScript is mandatory and strict. No
any. Model the domain with
precise, well-named types/interfaces — types are part of "semantically clean". - CSS values come from tokens only (
var(--…)); writepx, never
hand-writerem; no Tailwind. - One semantic class per element, short and local. No descendant selectors,
no BEM, no global selectors inside modules. - Components live at
components/<domain>/<Prefix><Name>.tsxwith a matching<Prefix><Name>.module.css(blocks/BlockForm.tsx,common/CommonButton.tsx). - Keep components focused (~150 lines = look for a split) but don't
over-fragment; when the right granularity is unclear, ask the user. - Use semantic HTML + basic a11y: correct element, heading order,
alt,
associated labels. - i18n via next-intl: import
Link/navigation from@/i18n/navigation
(nevernext/*); keepde/enkeys in sync; keys are type-checked. - Default to Server Components; add
'use client'only at interactive leaves. - Linters are the source of truth:
stylelintenforces the CSS rules andtscenforces the types — run them, don't fight them.
0. Bootstrapping a new project
npx create-next-app@latest→ choose App Router, TypeScript,
No Tailwind,@/*import alias,src/no (this house style
keepsapp/,components/,styles/,i18n/at the package root).- Install dependencies:
npm i next-intl npm i -D postcss postcss-import postcss-custom-media postcss-preset-env \ postcss-pxtorem @csstools/postcss-global-data \ stylelint stylelint-config-standard prettier - Copy the template tree from
templates/into the project:styles/(the whole 3-layer tree) ← the part that matters mostpostcss.config.cjsi18n/(routing.ts,request.ts,navigation.ts,messages/)global.d.ts(type-safe i18n messages + locale)middleware.tsapp/[locale]/layout.tsx+app/[locale]/page.tsx- example components (
components/common/CommonButton.*,CommonImage.tsx)
to anchor the pattern next.config.mjs(wires the next-intl plugin).stylelintrc.cjs+.prettierrc
- Confirm
tsconfig.jsonhas"strict": trueand"paths": { "@/*": ["./*"] }. - Add scripts to
package.json:"lint:css": "stylelint \"**/*.css\"","typecheck": "tsc --noEmit".
1. CSS Architecture — 3 layers
PostCSS contract: all CSS is processed through PostCSS. Write
px
everywhere —postcss-pxtoremconverts toremat build time. Never writeremby hand; it won't be converted and breaks the scaling contract.
(html/bodyare blacklisted so root font-size stays in px.)
The cascade is controlled with a single declared layer order at the top ofglobals.css:
@layer base, components, utilities;
@import './base/index.css';
@import './components/index.css';Layer 1 — Design tokens (styles/base/tokens.css)
Single source of truth for every visual value: colors, spacing, radii,
typography. Defined as CSS custom properties on :root.
- NEVER hardcode a color, spacing, or radius in component CSS
(no#3b82f6, no raw16pxfor spacing, no8pxfor radius). - ALWAYS reference via
var(--token-name). - Missing a value? Add the token to
tokens.cssfirst, then use it. - Fluid typography uses
clamp(min, preferred, max)— never JS for font
scaling. Responsive token jumps go in@media (--bp-*)blocks on:root.
Layer 2 — Base / reset (styles/base/base.css)
Minimal reset + base element typography (body, headings, links, button reset),
all inside @layer base. NEVER override base element styles from a component
module — fix them here.
Layer 3 — Components (CSS Modules)
Every component has a co-located .module.css. Class names are short, local,
and semantic — no BEM, no global selectors, no nested descendant
selectors (.root li, .root > a):
- Give every element its own class:
.root,.item,.link,.icon,.label. - JSX assigns each class explicitly:
<a className={styles.link}><Icon className={styles.icon} /></a>. - Inline
style={{}}is allowed only for truly runtime-dynamic values
(e.g. a computed width). Never for static design values.
Where does a layout class go?
| Type | Example | Where |
|---|---|---|
| Structural app/page wrapper | .layout, .grid |
styles/base/utilities.css → @layer utilities |
| Page/feature-specific layout | .heroSection, .sidebarLayout |
the page's page.module.css |
| Component-internal layout | .root, .wrapper, .header |
the component's .module.css |
Rule: appears in >1 place → @layer utilities. Used exactly once → that
page/component module. Global utilities and module classes combine freely in
JSX: className={\${styles.layout} grid`}`.
Keep global utilities minimal — only genuinely structural, app-wide
patterns (the centered .grid container, the .layout shell). Resist adding
generic helpers like .stack / .row: they describe how not what, which
fights the "semantic class per element" rule and drifts toward mini-Tailwind. A
two-line display: flex; gap: … is clearer written directly in the component's.root. Let utilities grow organically: promote a pattern only once it's
actually repeated.
Breakpoints — styles/base/media.css
CSS Level 5 @custom-media, made available to all files via@csstools/postcss-global-data.
- NEVER hardcode a breakpoint (
@media (max-width: 768px)). - ALWAYS use tokens:
@media (--bp-medium-up) { … }. - Mobile-first: default = mobile,
*-uptokens enhance upward. - ALWAYS nest media queries inside the selector, not as separate blocks.
.nav {
display: none;
@media (--bp-medium-up) {
display: flex;
}
}How CSS reaches a component
globals.cssis imported once, in the rootapp/[locale]/layout.tsx:import '@/styles/globals.css'.- Component styles are imported per file:
import styles from './CommonButton.module.css'. - Global atom styles that aren't tied to one React component
(e.g. a.buttonused in rich text / markup) live instyles/components/*.cssand are aggregated bystyles/components/index.css.
No Tailwind
No Tailwind classes in any JSX/TSX, no tailwind.config.js. Layout and spacing
are handled entirely via CSS Modules + tokens.
Styling a new component — the everyday workflow
This is the loop you'll run most often. Keep it tight:
- Create the folder
components/MyThing/withMyThing.tsx+MyThing.module.css. - In the
.tsx, give every styled element its own semantic class fromstyles(.root,.title,.list,.item,.icon) — no descendant
selectors, no styling bare tags. - In the
.module.css, write plainpxand referencevar(--…)tokens for
colors/spacing/radii. Need a value that has no token? Add it totokens.css
first. - Responsive tweaks go nested inside the selector with
@media (--bp-*-up). - If the same layout shows up again elsewhere → promote it to
@layer utilitiesinutilities.css.
Want a one-off custom look for a single instance? Compose classes in JSX —className={\${styles.root} grid`}` — rather than reaching for inline styles.
Pre-submit CSS checklist
- All color/spacing/radius values use
var(--…)tokens - Every component has a
.module.css; classes are short, local, no BEM - No nested descendant selectors inside modules
- No hardcoded values outside
tokens.css - No hand-written
rem(writepx) - No inline styles for static values
- No Tailwind
- Reusable layout in
@layer utilities, not duplicated - Responsive type uses
clamp(); breakpoints use@custom-media
2. Components folder — naming & file layout
Group by domain folder. Inside a domain, files are flat and the
filename is prefixed with the domain, with a co-located .module.css of the
exact same name:
components/
blocks/ # page sections — prefix Block
BlockForm.tsx
BlockForm.module.css
BlockAccordion.tsx
BlockAccordion.module.css
richTextBlocks/ # sub-group only when a domain grows large
BlockImage.tsx
BlockImage.module.css
common/ # shared low-level atoms — prefix Common
CommonButton.tsx
CommonButton.module.css
CommonImage.tsx
layout/ # structural shells — prefix Layout (LayoutWrapper…)
globals/ # site-wide singletons — prefix Global (GlobalHeader, GlobalFooter)
form/ # form + field components — prefix Form
context/ # React context providersNaming rules (match these exactly — this is the house convention):
- PascalCase, prefixed with the domain:
Block*,Common*,Layout*,Global*,Form*. The prefix tells you the folder at a glance. - The
.module.cssfilename mirrors the component:BlockForm.tsx→BlockForm.module.css. - Keep each domain folder flat; add a sub-folder (e.g.
richTextBlocks/) only
once a domain is big enough to warrant grouping. - Pick the domain by role: reusable atom →
common, page section →blocks,
structural shell →layout, site-wide singleton →globals. - Default to Server Components. Add
'use client'only when the component
needs state, effects, browser APIs, or event handlers.
Keep components small & maintainable — NOT automatic, be proactive
Left alone, a model produces one oversized file. Don't. Actively decompose so
the result is semantically clean and easy to maintain:
- One component = one responsibility. If you describe it with "… and …",
it should be two components. - Soft ceiling ~150 lines per
.tsx. Past that, find a self-contained
chunk to extract — don't keep scrolling. - Extract repeated or self-contained JSX into its own prefixed component in
the same domain (e.g.BlockFormrendersBlockFormFields). - Keep the render thin. Move data shaping into
utility/helpers or customhooks/; JSX should read like markup, not logic. - Co-locate truly local sub-parts; promote a piece to
common/only once
it's reused across domains. - Composition over prop explosion. A pile of boolean/variant props is a
smell — it's usually two clearer components.
Don't over-fragment, though. Small is a means, not the goal — splitting must
earn its keep. Don't spawn dozens of one-line files just to hit a line count.
Extract only when a chunk is genuinely reused, independently testable,
or conceptually its own thing. A cohesive 180-line component that reads top
to bottom beats five 30-line files you have to jump between. The ~150-line mark
is a prompt to look, not a hard cut.
When the right granularity is genuinely unclear → ask the user rather than
guessing. A quick "keep this as one component, or split out X and Y?" is better
than silently over- or under-splitting.
Semantic HTML & accessibility
"Semantically clean" means the markup, not just class names:
- Use the right element:
<button>for actions,<a>/<Link>for
navigation,<nav>/<header>/<main>/<footer>for landmarks,<ul>/<li>
for lists. Never a clickable<div>. - One
<h1>per page; don't skip heading levels — order by structure, size
with tokens (don't pick<h3>"because it looks right", style an<h2>). - Every image has a meaningful
alt(emptyalt=""only when decorative). - Form fields have an associated
<label>(htmlFor/id). - Interactive custom elements get the right
aria-*/role and are keyboard-usable.
Images
- Use
next/imagefor all raster images (lazy-loading, sizing, no layout
shift), via theCommonImagewrapper so handling stays consistent. - Always pass
alt, pluswidth/height(orfill+ a sized container).
Component "definition of done"
- File is
components/<domain>/<Prefix><Name>.tsxwith matching.module.css - Fully typed — explicit
Propsinterface, noany - Single responsibility; not over-large and not needlessly fragmented
- Server Component unless it genuinely needs the client
- Semantic HTML +
alt/labels where applicable - Every styled element has its own module class (no descendant selectors)
-
tsc --noEmitandstylelintpass
3. Internationalization — next-intl v4
Locale lives in the URL via a [locale] route segment. localePrefix is'as-needed' (the default locale has no prefix). Files:
i18n/
routing.ts # locales, defaultLocale, localePrefix (single source of truth)
request.ts # getRequestConfig → loads messages/<locale>.json per request
navigation.ts # locale-aware Link, redirect, usePathname, useRouter
messages/
de.json
en.json
middleware.ts # createMiddleware(routing) — locale negotiation + redirectsWiring:
next.config.mjswraps the config withcreateNextIntlPlugin('./i18n/request.ts').middleware.tsrunscreateMiddleware(routing)for locale negotiation and the/→ default-locale redirect (runs on Vercel's edge/runtime).app/[locale]/layout.tsxvalidates the locale, callssetRequestLocale(locale)
(enables static rendering) and wraps children in<NextIntlClientProvider>.generateStaticParamsreturns every locale so locale routes are pre-rendered.
Using translations:
- Server Components:
const t = await getTranslations('home')→t('title'). - Client Components:
const t = useTranslations('home')(must be under the provider). - Active locale:
useLocale()(client) /await getLocale()(server). - Linking: import
Link/useRouter/usePathnamefrom@/i18n/navigation,
never fromnext/*, or the locale prefix breaks. - Keep
de.json/en.jsonstructurally identical; keys are namespaced.
Adding a locale: add it to routing.ts locales + add messages/<locale>.json.
That is the only place locales are declared.
4. TypeScript — mandatory & semantic
TypeScript is required everywhere (no .js/.jsx source, no untyped
escapes). Types are part of clean, maintainable code — treat them with the same
care as the runtime code.
tsconfig.jsonruns with"strict": true. Keep it on.- No
any. If a type is truly unknown, useunknownand narrow. No@ts-ignorewithout a one-line justification comment. - Every component has an explicit
Propsinterface/type, named<Component>Props. Avoid inline anonymous prop objects for non-trivial props. - Model the domain with precise types: prefer string-literal unions over
barestring(type Variant = 'primary' | 'ghost'),readonlywhere data
isn't mutated, and discriminated unions over loose optional fields. - Derive, don't duplicate: use
typeof,keyof,Pick/Omit, and infer
from a single source (e.g. message keys come fromen.jsonviaglobal.d.ts). - Type at the boundary: API/JSON responses, params, and search params get a
type at the edge so the rest of the code is sound. - Run
tsc --noEmitas part of "done" — a green typecheck is non-negotiable.
5. Optional enhancements (suggest to the user)
Bring these up when relevant; don't add unprompted:
- ESLint (
eslint-config-next+@typescript-eslint) to complement
Prettier/Stylelint with code-logic rules. (Prettier + Stylelint are already
shipped as templates.) - A
cz/commit convention (the source repo uses Conventional Commits +
Husky pre-commit hooks runninglint:css+typecheck). next/fontfor self-hosted Google fonts exposed as CSS variables
(--font-sans→--font-body/--font-heading) — already in the layout template.generateMetadataper page for localized SEO + a localizedsitemap.- A
clsx/cnhelper for conditional class composition with CSS Modules.