Daniel Campos
HomeAboutThoughtsProjectsContact
Daniel Campos

Fullstack developer building practical software with clear interfaces and solid foundations.

Navigation

HomeProjectsThoughtsAbout

Social

ContactLinks

© 2026 Daniel Campos. All rights reserved.

IS
ArticleI stopped guessing where code lives in my Next.js app
Article

I stopped guessing where code lives in my Next.js app

DC
Daniel Campos
Share:
June 30, 20261 min read
DC
Daniel Campos
June 30, 20261 min read
Share:

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.

The headline

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.

What broke before the refactor

  1. One contact flow, four homes. app/api/contact/route.ts was ~150 lines mixing rate limits, CAPTCHA verification, Zod parsing, and Resend. The form component lived in app/contact/. Validation lived in lib/validations/. Rate limiting lived in lib/rate-limit.ts.
  2. Sanity everywhere. GROQ queries, the client, image builders, and cache wrappers were under lib/sanity/. Page files in app/ imported them directly. Changing a fragment meant grep across a dozen routes.
  3. Duplicate UI trees. PostCard lived under components/blog/. ProjectCard under components/projects/. Hero sections under components/home/. The same domain also had client wrappers in app/. You could fix a bug in one tree and miss the mirror.
  4. Tests that lied about ownership. Twenty-plus files under __tests__/ mirrored lib/ and app/api/. When code moved, tests did not move with it. Failures pointed at paths that no longer existed.

What I wanted instead

Four rules. Non-negotiable once ESLint enforces them:

  1. One-way dependencies. app/ and components/ import features/. features/ imports services/ and lib/. services/ never imports features/. One feature never imports another feature's internals.
  2. Services for every external system. Sanity, Resend, Upstash, reCAPTCHA, license file I/O: each gets a thin typed wrapper under services/. No @sanity/client imports in features/ except through services/sanity/client.ts.
  3. Structural server/client boundary. Secrets and write tokens are not importable from "use client" files, even transitively. Client code may only touch services/*/public/ modules (reCAPTCHA action names, image URL builders).
  4. One mutation path. Server Actions in features/<name>/actions.ts handle form posts and writes. app/api/* stays for stable external URLs only: Sanity webhooks, file downloads. Not both for the same flow.

The target tree

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.

tree.txttext
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
LayerOwnsMay importMust not
app/URLs, segment configfeatures/, components/services/ directly
features/Page logic, domain UI, Server Actionsservices/, lib/, components/, hooks/other features/
services/SDK calls, env reads, typed errorslib/, external packagesfeatures/

Proof: the contact form

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.tstypescript
// 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.

Proof: thin routes

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.tsxtypescript
// 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.

Proof: Sanity as a service

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.tstypescript
// 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.

Proof: ESLint makes it stick

Conventions drift without enforcement. I added no-restricted-imports rules in eslint.config.ts:

  • features/home/ cannot import from features/about/ (or any other feature). Shared code goes to services/, lib/, or hooks/.
  • Client files in a feature cannot import @/services/upstash, @/services/resend, or @/services/sanity/client.
  • lib/ cannot import features/ and may only reach services/*/public/.
eslint.config.tstypescript
// 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.

How to implement this on your project

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.

  1. Phase 0: services foundation. Move Sanity client + queries into services/sanity/. Add services/upstash.ts, services/resend.ts, services/recaptcha/. Consolidate env into root env.ts with Zod validation.
  2. Phase 1: one mutation as a Server Action. Pick your contact form (or smallest write path). Create features/contact-form/ with schema.ts, actions.ts, and the form component. Delete the matching API route. Confirm the client calls the action, not fetch("/api/...").
  3. Phase 2: webhooks that need stable URLs. Keep app/api/revalidate/route.ts as a thin wrapper around services/sanity/revalidate.ts. Same for download routes. Three lines in the route, logic in the service.
  4. Phase 3: page features. One route at a time: create features/thoughts/thoughts-page.tsx, re-export from app/thoughts/page.tsx. Move PostCard, filters, and list-params into the feature. Delete the old components/blog/ mirror.
  5. Phase 4: SEO and metadata. Centralize PAGE_SEO_QUERY fetches in services/sanity/page-seo.ts. Features call it from generateMetadata.
  6. Phase 5: tests and boundaries. Colocate tests: features/**/*.test.ts, services/**/*.test.ts, env.test.ts next to env.ts. Add ESLint import rules. Delete the __tests__/ mirror tree.

Do not rename env vars or change API response shapes during the structural pass. Move code first. Refactor behavior second.

What still fights me

Honest friction points, not a polished after photo:

  • revalidate must stay in every app/*/page.tsx. Two duplicated lines per route. Next.js segment config does not re-export cleanly.
  • lib/projects/list-params.ts did not move into features/projects/ yet. thoughts/list-params.ts did. Asymmetric on purpose to ship faster.
  • components/blocks/ (HomeBand, LetsTalkCTA) stayed cross-cutting while HeroSection moved to features/home/. Blocks are composed by the home feature but live outside it.
  • One-off scripts (backfill-reading-time.ts) still use @sanity/client directly. App code does not get that exemption.
  • README still mentioned POST /api/contact for a week after the route was deleted. Docs lag structure.

What you should do Monday

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.

DC

Daniel Campos

Fullstack Developer

Share:
components/Layout, shared primitives, blockslib/, services/*/public/server services/
lib/Pure helperslib/, services/*/public/features/, I/O