I added a contact form field last year. The change touched an API route, a Zod schema in lib/, reCAPTCHA helpers in another lib/ folder, and a React component under app/contact/. The form worked. I still spent twenty minutes tracing imports before I knew where to edit.
That was the pattern everywhere. Sanity GROQ lived in lib/sanity/queries.ts. Resend was imported straight into route handlers. Hero sections sat in components/home/ while the home page logic sat in app/. The site shipped fine. Every new feature started with a scavenger hunt.
The fix was not a framework upgrade or a monorepo. It was a folder contract: features own business logic, services own external systems, app/ owns URLs only. Same pages, same env vars, same deploy. I refactored my Next.js 16 App Router site in phases so production never broke.
If your App Router project has grown past a handful of routes, you need an enforced boundary between routing, domain logic, and integrations. Without it, you keep paying a tax on every change.
This is the layout I landed on, the four rules that hold it together, the migration order I used, and the parts that still annoy me.
Four rules. Non-negotiable once ESLint enforces them:
Do not create every folder on day one. Create features/<first-feature>/ and services/<first-integration>/ when you write the first real feature. Let the rest grow.
app/ # routing only (thin re-exports)
features/ # one folder per business capability
contact-form/ # actions.ts, schema.ts, contact-form.tsx
thoughts/ # thoughts-page.tsx, PostCard, list-params.ts
projects/ # projects-page.tsx, ProjectCard, filters
home/ # home.tsx, HeroSection, ProjectsSlider
services/ # every external system gets a typed wrapper
sanity/
client.ts # sanityFetch + cache tags
queries/ # posts.ts, projects.ts, home.ts, seo.ts
public/ # image.ts, file.ts (client-safe)
recaptcha/public/ # constants.ts (client-safe)
upstash.ts # rate limiting
resend.ts # email
components/ # layout, shared UI, blocks (no domain mirrors)
lib/ # pure utilities (no I/O, no env)
hooks/ # cross-feature client hooks
env.ts # single Zod-validated env module| Layer | Owns | May import | Must not |
|---|---|---|---|
| app/ | URLs, segment config | features/, components/ | services/ directly |
| features/ | Page logic, domain UI, Server Actions | services/, lib/, components/, hooks/ | other features/ |
| services/ | SDK calls, env reads, typed errors | lib/, external packages | features/ |
This is the flow that sold me on the layout. /api/contact is gone. The client form in features/contact-form/contact-form.tsx calls submitContactForm directly. The Server Action orchestrates services in a fixed order: payload size, rate limit, Zod, reCAPTCHA, Resend. Each step returns early on failure.
// features/contact-form/actions.ts
export async function submitContactForm(input) {
const sizeCheck = assertContactPayloadSize(input)
if (!sizeCheck.ok) return { success: false, error: 'payload_too_large' }
const rateLimit = await checkRateLimit(clientIp)
if (!rateLimit.success) {
return { success: false, error: 'rate_limited', retryAfterSeconds: rateLimit.retryAfterSeconds }
}
const parsed = contactSchema.safeParse(input)
if (!parsed.success) return { success: false, error: 'validation_error', fields }
const recaptcha = await verifyContactRecaptcha(recaptchaToken, { userIpAddress: clientIp })
if (!recaptcha.ok) return { success: false, error: recaptcha.error }
return sendContactEmail(parsed.data)
}The client never imports @/services/resend or @/services/upstash. It only pulls RECAPTCHA_CONTACT_ACTION from services/recaptcha/public/constants.ts. The Zod schema in features/contact-form/schema.ts is shared by the form and the action. One shape, defined once.
That is the pattern for every mutation: services do I/O, features orchestrate, clients stay dumb.
Every page route is two or three lines. Segment config stays in app/ because Next.js does not let you re-export export const revalidate from a feature module. Everything else moves out.
// app/thoughts/page.tsx
import { SANITY_REVALIDATE_SECONDS } from '@/services/sanity/revalidate-seconds'
export const revalidate = SANITY_REVALIDATE_SECONDS
export { default, generateMetadata } from '@/features/thoughts/thoughts-page'app/page.tsx, app/about/page.tsx, app/contact/page.tsx, and app/projects/page.tsx follow the same shape. Slug routes re-export generateStaticParams from the feature too.
Queries split by domain under services/sanity/queries/. posts.ts owns post list and detail fragments. projects.ts owns project queries. seo.ts owns PAGE_SEO_QUERY. One barrel re-exports them.
// services/sanity/queries/index.ts
export { POSTS_PAGE_WITH_COUNT_NEWEST, POST_BY_SLUG } from './posts'
export { PROJECTS_PAGE_WITH_COUNT, PROJECT_BY_SLUG } from './projects'
export { HOME_PAGE_QUERY } from './home'
export { PAGE_SEO_QUERY } from './seo'sanityFetch in services/sanity/client.ts attaches Next cache tags. getPostBySlug in services/sanity/cached.ts wraps React cache() so generateMetadata and the page component do not double-fetch on the same request. Features import these functions. They do not construct GROQ inline.
Conventions drift without enforcement. I added no-restricted-imports rules in eslint.config.ts:
// eslint.config.ts: features/home cannot import features/about
function crossFeaturePatterns(current) {
return FEATURES
.filter((feature) => feature !== current)
.map((other) => ({
group: [`@/features/${other}`, `@/features/${other}/**`],
message: `Promote shared logic to services/, lib/, or hooks/.`,
}))
}The first time ESLint blocked a shortcut import, the layout stopped being optional.
Do not big-bang rename folders on a Friday. Extract the messiest integration first, point one feature at it, run lint + tests + build, then repeat.
Do not rename env vars or change API response shapes during the structural pass. Move code first. Refactor behavior second.
Honest friction points, not a polished after photo:
Open the file you dread touching most. That is your Phase 0. If it imports an SDK directly, extract a services/ wrapper. If it mixes routing and business logic, sketch the feature module it belongs in. Run one green build before the next extraction.
The goal is not a pretty tree in a blog post. The goal is knowing exactly which folder to open when product asks for one more field on the form. That is the tax this layout eliminates.
| components/ | Layout, shared primitives, blocks | lib/, services/*/public/ | server services/ |
| lib/ | Pure helpers | lib/, services/*/public/ | features/, I/O |