I've shipped three production apps with the Next.js App Router now. Here are the patterns I keep reaching for — and a few I tried and abandoned.
Colocate page-specific components
The App Router makes it easy to drop components directly into route folders. I use this aggressively:
app/
projects/
page.tsx
_components/
project-filter.tsx ← only used by this route
sort-menu.tsx
blog/
page.tsx
The underscore prefix (_components) prevents the folder from being treated as a route segment. Anything used by more than one route goes up to components/ at the root.
Server Components as data-fetching boundaries
My default is to start everything as a Server Component and only add "use client" when I need interactivity or browser APIs. This means data fetching happens at the component level, not in a centralized store:
// app/projects/page.tsx — no useState, no useEffect, no loading states
export default async function ProjectsPage() {
const projects = await db.query.projects.findMany()
return <ProjectGrid projects={projects} />
}
The result is less client-side JavaScript, faster initial loads, and simpler code. The tradeoff is that you need to think more carefully about what belongs at which layer.
Parallel routes for modals
Parallel routes (@modal folders) are the App Router's answer to modals that have their own URL. I use this for project detail views — clicking a project card updates the URL and opens a modal, but the underlying list stays visible.
app/
projects/
@modal/
(.)projects/[slug]/
page.tsx ← intercepted modal view
[slug]/
page.tsx ← full page view (direct navigation)
page.tsx
layout.tsx
When you navigate directly to /projects/my-project, you get the full page. When you click through from the list, the URL updates but you stay on the list with the modal overlaid. It's a nice pattern for content-heavy lists.
Route groups for shared layouts
Route groups ((groupName)) let you share a layout without adding a URL segment. I use them to create layout zones:
app/
(marketing)/ ← full-width layout
layout.tsx
page.tsx
about/
(app)/ ← sidebar layout
layout.tsx
dashboard/
settings/
Both groups coexist without any /marketing/ or /app/ prefix in the URL.
loading.tsx at the right level
loading.tsx creates a Suspense boundary for the entire route segment. Placing it too high means unrelated parts of the page show a spinner when one section loads. I try to place loading.tsx as close to the actual async work as possible:
app/
dashboard/
page.tsx ← fast, synchronous shell
recent-activity/
page.tsx ← slow async component
loading.tsx ← spinner only for this section
Streaming with Suspense
For pages with a mix of fast and slow data, I lean on Suspense directly rather than loading.tsx:
export default function DashboardPage() {
return (
<div>
<Header /> {/* instant */}
<QuickStats /> {/* instant */}
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity /> {/* async, streams in */}
</Suspense>
</div>
)
}
The slow component streams in after the fast shell is already visible. Users see content sooner even if the page isn't fully loaded.
What I abandoned
useSearchParams for filters. It works, but syncing UI state with search params requires jumping through hoops (useTransition, router.replace, wrapping in Suspense). For simple filter state I've gone back to useState unless shareability via URL is a hard requirement.
Layout-level data fetching. I tried putting data fetches in layouts so child routes could access them via context. The waterfall issue made it not worth it — each layout waits for its parent before fetching. Better to fetch independently in each page and accept some redundancy.
The App Router is genuinely good once the mental model clicks. The shift from "pages are special files" to "the file system describes your UI tree" takes a few projects to internalize, but it pays off.