Introduction
Let's be honest - Next.js has completely changed how we build React apps. With the release of Next.js 13 and the stabilization in version 14, the new App Router has turned everything I knew about routing on its head - and I'm loving it.
If you're just getting started with Next.js, this new approach takes some getting used to.
In this blog, we will talk about the App Router, how it actually works in practice, and some cool tricks you can use to leverage your app.
What is the App Router?
Instead of throwing everything into that /pages
directory we've all grown familiar with, you now organize your app under /app
. It might seem like a simple change, but it completely transforms how you think about your application structure.
The App Router isn't just about changing folders - it's about embracing React Server Components, streaming, and nested layouts in a way that actually makes sense in production.
Directory Structure: It's All About Conventions
Let me show you how I typically structure a project now:
/app
/dashboard
page.tsx # This gives you /dashboard
layout.tsx # Shared UI wrapper that stays consistent
loading.tsx # That nice loading spinner users see
error.tsx # When things break (and they will)
/profile
page.tsx # Your /profile route
layout.tsx # The root layout wrapping everything
page.tsx # Your homepage (/)
The key files I work with daily:
-
page.tsx
: This is the actual page content -
layout.tsx
: Where I put navigation, sidebars, and other persistent UI -
template.tsx
: Like layout but re-mounts between navigations (great for animations) -
loading.tsx
: My fallback UI when data is loading -
error.tsx
: Custom error handling per route (a lifesaver in production)
Layouts: Keep the Good Parts, Change the Rest
One thing I absolutely love about the App Router is how layouts work. In the old Pages Router, I'd have to wrap everything in a _app.tsx
file and handle layout transitions manually.
Now, I just create layout files where needed:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>
<Navbar />
<main>{children}</main>
</body>
</html>
);
}
The best part? The <Navbar />
stays put during navigation, with only the children
updating. No more full-page refreshes or navbar flickers. Your users will thank you.
Server vs Client Components: The Mental Model That Took Me Weeks to Get Right
This was honestly the biggest hurdle for me. In the App Router, everything is a server component by default. That means your components run on the server unless you explicitly opt out.
When I need interactivity, I add the 'use client' directive:
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
It took me a while to develop the right mental model here, but now I think: "Server component until proven interactive." This approach has drastically reduced the JavaScript I'm sending to my users.
Routing: From Simple to Complex in No Time
Basic routes are straightforward - create a folder, add a page.tsx file, and you're done:
app/about/page.tsx → /about
But where things get interesting is with dynamic routes:
app/blog/[slug]/page.tsx → /blog/:slug
I access the params like this:
export default function BlogPost({ params }) {
return <h1>Post: {params.slug}</h1>;
}
Need to handle arbitrary depth? Catch-all routes have you covered:
app/docs/[...slug]/page.tsx → /docs/a/b/c
I've also fallen in love with parallel routes (using @
folders) and intercepting routes (with the .
prefix) for building modals that don't break deep linking. Game changer for complex UIs.
Loading States That Don't Make Users Wait
This is where the App Router truly shines. Instead of showing a blank screen or a full-page loader, I can now create targeted loading states:
// app/dashboard/loading.tsx
export default function Loading() {
return <p>Loading dashboard data...</p>;
}
Combined with React Suspense and streaming, my apps now feel instantly responsive even when data takes a second to load.
Error Handling That Doesn't Crash Your Entire App
We've all been there - one API call fails and your entire app shows the dreaded error page. With the App Router, I can isolate errors to specific routes:
// app/profile/error.tsx
'use client';
export default function Error({ error, reset }) {
return (
<div>
<p>Sorry, we couldn't load your profile: {error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
The rest of the app keeps working, and users can retry the failed operation. My support tickets have dropped dramatically since implementing this pattern.
API Routes: Simplified and Co-located
API routes have also gotten a makeover. Instead of the old /pages/api
approach, I now use route handlers:
// app/api/hello/route.ts
export async function GET() {
return Response.json({ message: 'Hello from API!' });
}
I can export GET, POST, PUT, DELETE directly as functions, which feels much more intuitive than the old switch (req.method)
pattern.
Conclusion
The App Router is clearly designed with modern applications in mind. It embraces server components, streaming, and composition in ways that make complex UIs manageable. Whether you're building a SaaS dashboard, an e-commerce site, or a content platform, the App Router gives you powerful tools that grow with your application. If you like this blog and want to learn more about Frontend Development and Software Engineering, you can follow me on Dev.to.