One Month With App Router: A Field Diary
One Month With App Router: A Field Diary#
After starting a new greenfield project, something about Server Components kept nagging — worth trying properly rather than avoiding because of earlier beta rough edges.
All-in on the App Router, Server Components, the whole thing. What follows is roughly what my notes looked like over the first month. I've cleaned up the language but not the opinions — some of them changed week to week.
Week 1: Everything Is a Server Component and I Don't Understand Layouts#
First day: created a new Next.js 13 app with the App Router flag. Opened the app/ directory. Stared at layout.tsx for longer than I'd like to admit.
In the Pages Router, I knew exactly what every file did. A file at pages/dashboard/settings.tsx was a page. Full stop. Now I've got a folder for each route and inside it there's page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx... My first reaction was "this is overengineered."
Here's what the file system looks like for the project I'm building (a multi-tenant SaaS thing):
app/
layout.tsx # Root HTML shell (html, body, providers)
(marketing)/ # Route group — invisible in the URL
page.tsx # Landing page
about/page.tsx
(app)/ # Route group — authenticated section
layout.tsx # Auth check + sidebar
dashboard/
layout.tsx # Dashboard-specific header
page.tsx # Dashboard overview
settings/
page.tsx # Settings page
loading.tsx # Skeleton while settings load
error.tsx # Error boundary for settings
The parentheses folders (route groups) confused me for two days. They're invisible in the URL but let you share layouts between routes without nesting appearing in the path. Once I got it, I liked it. But the documentation at this point was... not great.
Week 2: OK Layouts Actually Make Sense#
This is where things clicked. In the Pages Router, keeping a sidebar mounted across pages required _app.tsx gymnastics that I'd always hated. You'd navigate from /dashboard to /dashboard/settings and the whole sidebar would unmount and remount. Flash of white. Re-fetch the user. Bad.
In the App Router, the layout just... stays:
// app/(app)/layout.tsx
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { AppSidebar } from "@/components/app-sidebar";
import { TopBar } from "@/components/top-bar";
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session) {
redirect("/login");
}
// this layout stays mounted during navigation
// sidebar doesn't flash, user session doesn't re-fetch
return (
<div className="flex h-screen overflow-hidden">
<AppSidebar user={session.user} />
<div className="flex flex-col flex-1 overflow-hidden">
<TopBar user={session.user} />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
</div>
);
}
This layout runs server-side, checks auth once, and wraps every route under (app)/. Navigate between pages? Only the children slot re-renders. The sidebar and top bar stay put.
A colleague who'd been on the App Router since the early beta confirmed this is the whole point of the layout system.
Week 3: Wait, I Need "use client" for an onClick Handler?#
OK so here's the mental model shift that took me the longest: every component in the App Router is a Server Component by default. They run on the server. They can be async. They can query databases directly. They send finished HTML to the browser.
// app/(app)/dashboard/page.tsx
// This is a Server Component — no "use client" anywhere
import { getMongoDb } from "@/lib/infrastructure/mongodb";
export default async function DashboardPage() {
const db = await getMongoDb();
// just... querying the database. in a component. on the server.
const [recentActivity, summaryStats] = await Promise.all([
db.collection("activity_events")
.find({ organizationId: "org_123" })
.sort({ createdAt: -1 })
.limit(10)
.toArray(),
db.collection("transactions")
.aggregate([
{ $match: { organizationId: "org_123" } },
{ $group: { _id: null, total: { $sum: "$amount" } } },
])
.toArray(),
]);
return (
<div className="space-y-6">
<SummaryCards stats={summaryStats[0]} />
<RecentActivityList items={recentActivity} />
</div>
);
}
No useEffect. No loading state variable. No API route in the middle. The HTML arrives at the browser already populated with data.
But then I tried to add a button with an onClick handler and got an error. Because Server Components can't have event handlers. Can't use useState. Can't use any hooks that rely on browser APIs.
So you add "use client" at the top of a file and that creates a client boundary. Everything imported by that file also becomes part of the client bundle. Which means you want to push "use client" as far down the tree as possible.
// app/(app)/dashboard/settings/page.tsx — Server Component (no directive)
import { getOrganizationSettings } from "@/modules/rbac/infrastructure/actions";
import { SettingsForm } from "./settings-form"; // this is the client island
export default async function SettingsPage() {
const settings = await getOrganizationSettings();
return (
<div className="max-w-2xl">
<h1 className="text-2xl font-semibold mb-6">Organization Settings</h1>
<SettingsForm initialValues={settings} />
</div>
);
}
// app/(app)/dashboard/settings/settings-form.tsx
"use client";
import { useActionState } from "react";
import { updateOrganizationSettings } from "@/modules/rbac/infrastructure/actions";
export function SettingsForm({ initialValues }) {
const [state, formAction, isPending] = useActionState(
updateOrganizationSettings,
{ error: null }
);
return (
<form action={formAction} className="space-y-4">
{/* form fields here */}
<button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save Changes"}
</button>
{state.error && <p className="text-red-600">{state.error}</p>}
</form>
);
}
Big Server Component tree. Small client "islands" for interactive bits. This took me about a week to internalize but once it clicked, the architecture felt right.
Week 4: Suspense Makes Loading States Trivial#
The loading.tsx file in a route segment automatically wraps page.tsx in a <Suspense> boundary. I didn't fully appreciate this until I had a dashboard with one fast query and one slow query.
// app/(app)/dashboard/page.tsx
import { Suspense } from "react";
import { ActivityFeedSkeleton } from "@/components/skeletons";
export default async function DashboardPage() {
// SummaryCards is fast — small aggregation
const stats = await fetchSummaryStats();
return (
<div className="space-y-6">
<SummaryCards stats={stats} />
{/* ActivityFeed fetches more data — show skeleton while it loads */}
<Suspense fallback={<ActivityFeedSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}
// Also a Server Component — fetches its own data
async function ActivityFeed() {
const events = await fetchRecentActivity();
return <RecentActivityList items={events} />;
}
SummaryCards renders immediately. ActivityFeed streams in with a skeleton in the meantime. No orchestration code. No useState for loading. It just works.
Coming from the Pages Router where loading states required three state variables per component, this felt almost too simple.
Month 1: Error Boundaries and the "Aha" Moment#
The error.tsx file must be a Client Component (React error boundaries require class components internally, Next.js wraps it for you):
// app/(app)/dashboard/settings/error.tsx
"use client";
export default function SettingsError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<h2 className="text-lg font-semibold text-red-900 mb-2">
Something went wrong
</h2>
<p className="text-red-700 text-sm mb-4">{error.message}</p>
<button
onClick={reset}
className="rounded-md bg-red-600 px-4 py-2 text-white text-sm hover:bg-red-700"
>
Try again
</button>
</div>
);
}
By month end, the "aha" moment wasn't any single feature. It was the realization that the App Router is a different mental model for building React apps, not just a different folder structure. You stop thinking "this component fetches its own data with hooks" and start thinking "the Server Component tree is responsible for getting data, and client components receive it as props."
That shift changes everything. The code is shorter. The pages feel faster. The bundle is smaller.
Things That Bit Me#
These are things that bit me during the first month:
Putting "use client" on the layout file. Bad idea. Your root layout needs to stay a Server Component. Providers that need "use client" should be extracted into a separate Providers wrapper component.
Passing a Date object from a Server Component to a Client Component. Dates come through as strings. Class instances lose their methods. Pass plain objects and primitives.
Forgetting that loading.tsx wraps the entire segment. If you want a loading state for just part of a page, use explicit <Suspense> boundaries inside the page component.
Trying to use React Context in Server Components. Context is a client-side primitive. If you need to pass data through a Server Component tree, just pass it as props. Wild concept, I know.
TL;DR#
One month in: I'm not going back to Pages Router for new projects. The layout system alone is worth the migration pain. Server Components make data fetching dramatically simpler — I've deleted hundreds of lines of useEffect boilerplate and the pages are genuinely faster. The "push the client boundary down" pattern leads to smaller bundles almost by accident.
But it is a new mental model. Not a small upgrade, not a different API — a fundamentally different way of thinking about where data comes from and where interactivity lives. If you're starting the migration, give yourself at least two weeks before you form opinions. My week 1 opinions were mostly wrong.