A Friendly Intro to the Next.js App Router

By LearnWebCraft Team14 min readIntermediate
Next.js App RouterNext.js 14ReactServer Components

Alright, so let’s talk. If you’ve been in the React world for any length of time, you’ve probably heard the whispers—or maybe the full-blown shouting—about the Next.js App Router. Maybe you’re coming from the good ol’ pages directory, feeling a bit like your cheese has been moved. I totally get it. I was right there with you.

For years, the pages router was our comfort zone. It was simple, predictable, and it just worked. Then came Next.js 13, and now 14, with this massive paradigm shift. It felt a little jarring, right? Suddenly we’re all talking about Server Components, layouts, and this completely new way of thinking about our apps.

But here’s the thing I realized after spending a ton of time with it: this isn't just change for the sake of change. The App Router is genuinely a massive leap forward. It’s faster, more powerful, and once it clicks, it makes for a much better developer experience.

This isn't going to be one of those dry, academic tutorials. My goal is to walk through this together, like a couple of developers figuring things out over a coffee. We'll cover what you actually need to know to get started and feel confident building with this new beast.

So, What's the Big Deal, Anyway?

Before we dive into any code, let's just pause and get one thing straight: why did Next.js do this to us? The answer, in two words, is React Server Components.

This is the foundational concept that the entire Next.js App Router is built on top of. For a super deep dive, you can always check out the official React docs, but here’s the gist of it in plain English:

For what feels like forever, we’ve been shipping these huge bundles of JavaScript to the client. The user’s browser has to download, parse, and execute all of that code just to see a webpage. We've all felt that pain. Server Components completely flip that on its head.

By default, components in the App Router run on the server. Think about that for a second. They render their HTML on the server and send that to the browser. This means way less JavaScript for the user to download, leading to a much faster initial page load. It's an absolute game-changer for performance.

You can now do things that always felt a little clunky before—like fetching data directly inside your component using async/await—without ever exposing that logic to the client.

Does that mean everything is server-only now? Nope. You can still have all the interactive, stateful components that run on the client. You just have to be explicit about it. This server-first-but-client-when-you-need-it approach is really the core philosophy here.

Getting Our Hands Dirty: The Setup

Okay, enough talk. Let’s build something. The best way to learn this stuff is by doing, right? Pop open your terminal and let’s get a new Next.js project started.

npx create-next-app@latest my-awesome-app

The installer is going to ask you a few questions. This is my go-to setup, and I'd highly recommend it for any modern project:

✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … No  (Personal preference, keep it simple for now)
✔ Would you like to use App Router? … Yes  (This is the whole point!)
✔ Would you like to customize the default import alias? … No

Once that’s all done, cd into your new project and fire up the development server.

cd my-awesome-app
npm run dev

Visit http://localhost:3000 in your browser, and you should see the default Next.js starter page. Congrats, you're officially up and running on the App Router.

The New Lay of the Land: Project Structure

Alright, go ahead and open the project in your code editor. The first thing you'll probably notice is the shiny new app directory. This is your new home. For new projects, you can pretty much forget about the pages directory; it’s a relic of the past.

Here’s a quick tour of what you’ll see inside:

my-awesome-app/
└── app/
    ├── layout.tsx       # The root layout for your entire application
    ├── page.tsx         # The homepage, equivalent to pages/index.tsx
    └── globals.css      # Your global styles, as you'd expect

The two files you really need to care about right now are page.tsx and layout.tsx.

  • page.tsx: This file defines the actual UI for a specific route. Every folder that should be a public URL needs a page.tsx file inside it. You can think of it as the main "content" of the page.
  • layout.tsx: This is a wrapper. It defines a UI that is shared across multiple pages. The one in the root app directory wraps your entire application—it's where you’d put your <html> and <body> tags, headers, footers, that sort of thing.

This separation is, frankly, brilliant. Layouts preserve their state and don't re-render when you navigate between pages nested inside them. It's a huge performance win and simplifies a lot of state management headaches.

Routing Reimagined: Folders Are King

The routing system is still based on the file system, but I think it's way more intuitive now. It’s all about folders.

Want a new page at /about? You just create a new folder named about inside the app directory, and then add a page.tsx file inside of it. Simple as that.

app/
├── about/
│   └── page.tsx      # This will be served at yoursite.com/about
└── page.tsx          # This is your homepage at yoursite.com/

Let's actually create that about page. Make the app/about/page.tsx file and toss in some simple content:

// app/about/page.tsx
export default function AboutPage() {
  return (
    <main className="p-24">
      <h1 className="text-4xl font-bold">About Us</h1>
      <p className="mt-4">
        We're just a team of developers who got really excited about the App Router!
      </p>
    </main>
  );
}

Now, navigate to http://localhost:3000/about. Boom. It just works. You didn't have to configure anything. The folder structure is your routing configuration.

What About Dynamic Routes?

This was always a key feature, and don't worry, it’s just as easy. Let's say we want a blog where each post has a unique slug, like /blog/my-first-post.

All you do is create a folder with square brackets [] around whatever you want your dynamic segment to be called.

  1. Create a folder: app/blog/[slug]/.
  2. Inside that, create your page.tsx.

The structure ends up looking like this:

app/
└── blog/
    └── [slug]/
        └── page.tsx   # This handles /blog/anything-at-all

The cool part is that the component inside app/blog/[slug]/page.tsx will automatically receive the slug as a prop.

// app/blog/[slug]/page.tsx
interface BlogPostProps {
  params: {
    slug: string;
  };
}

export default function BlogPostPage({ params }: BlogPostProps) {
  // You can use the slug to fetch data for this specific post
  const { slug } = params;

  return (
    <main className="p-24">
      <h1 className="text-4xl font-bold capitalize">
        Viewing Blog Post: {slug.replace(/-/g, ' ')}
      </h1>
      <p className="mt-4">This is where the content for "{slug}" would go.</p>
    </main>
  );
}

Now try visiting http://localhost:3000/blog/hello-world or http://localhost:3000/blog/learning-nextjs in your browser. It’s incredibly powerful and feels really natural once you get the hang of it.

The Big Shift: Server vs. Client Components

Okay, lean in, because this is the most important part of the entire tutorial. Getting this concept right will make everything else just fall into place.

By default, every single component inside the app directory is a React Server Component (RSC).

This means it runs exclusively on the server. It has no state, no effects, and no access to browser APIs like window or localStorage. It's just pure presentation logic that can fetch data and render HTML.

Take a look at our BlogPostPage component again. It's a server component. This means we could fetch data right inside it:

// app/blog/[slug]/page.tsx

// A pretend function to fetch post data
async function getPostData(slug: string) {
  // In a real app, you'd fetch from a database or a CMS
  // We'll simulate a network delay to make it obvious
  await new Promise(resolve => setTimeout(resolve, 500)); 
  
  return {
    title: `This is the Title for ${slug}`,
    content: "Lorem ipsum dolor sit amet, consectetur adipiscing elit...",
  };
}

interface BlogPostProps {
  params: {
    slug: string;
  };
}

export default async function BlogPostPage({ params }: BlogPostProps) {
  const post = await getPostData(params.slug);

  return (
    <main className="p-24">
      <h1 className="text-4xl font-bold">{post.title}</h1>
      <p className="mt-4">{post.content}</p>
    </main>
  );
}

Did you see that? We just made our component async and used await right inside it. There's no useEffect, no useState, no client-side loading spinners to manage. The component simply waits for the data on the server, renders the final HTML, and sends that down to the browser. This is huge.

But I Need Interactivity!

Okay, so what happens when you need a button that actually responds to clicks? Or a form that needs to manage state? That's when you reach for a Client Component.

To turn a component into a Client Component, you just add the "use client"; directive at the very top of the file. It's like a little flag that tells Next.js, "Hey, this one needs to run in the browser. Bundle it up and send it over."

Let’s make a simple counter to see it in action. Create a new file: app/components/Counter.tsx.

// app/components/Counter.tsx
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div className="mt-8 p-4 border rounded-lg">
      <p>You clicked the button {count} times.</p>
      <button 
        className="mt-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
        onClick={() => setCount(count + 1)}
      >
        Click me
      </button>
    </div>
  );
}

This looks like the standard React code you’ve probably written a thousand times, with useState and an onClick handler. The only real difference is that little 'use client'; at the top.

And here's the best part: you can import and use this interactive Client Component right inside a Server Component!

Let's go back to app/blog/[slug]/page.tsx and add it:

// app/blog/[slug]/page.tsx
import Counter from '@/app/components/Counter'; // Assuming default alias

// ... (getPostData function remains the same)

export default async function BlogPostPage({ params }: BlogPostProps) {
  const post = await getPostData(params.slug);

  return (
    <main className="p-24">
      <h1 className="text-4xl font-bold">{post.title}</h1>
      <p className="mt-4">{post.content}</p>

      {/* You can use a Client Component right inside a Server Component! */}
      <Counter />
    </main>
  );
}

This is the core pattern. You keep as much of your app as Server Components as possible for maximum performance, and then you sprinkle in Client Components wherever you need that browser-based interactivity. The parent component (BlogPostPage) does all the heavy data lifting on the server, and the child component (Counter) handles its own state on the client. It really is the best of both worlds.

Linking It All Together: Navigation

A site isn't much use if you can't actually get between pages. Next.js provides a <Link> component for this, and it’s been supercharged in the App Router.

Let's create a simple navigation header. Make a new file at app/components/Header.tsx:

// app/components/Header.tsx
import Link from 'next/link';

export default function Header() {
  return (
    <header className="p-4 bg-gray-800 text-white">
      <nav className="container mx-auto flex gap-4">
        <Link href="/" className="hover:underline">Home</Link>
        <Link href="/about" className="hover:underline">About</Link>
        <Link href="/blog/my-first-post" className="hover:underline">First Post</Link>
      </nav>
    </header>
  );
}

Now, where should we put this? In our root layout, of course! That way, it will show up on every single page of our application without us having to import it everywhere.

Open up app/layout.tsx and add the Header component.

// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import Header from './components/Header'; // Import the header

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'My Awesome App Router Site',
  description: 'Learning Next.js is fun!',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Header /> {/* Add the header right here */}
        {children}
      </body>
    </html>
  );
}

Save your files, and you should now see the header on all your pages. Click the links. Notice how fast that navigation is? That's because next/link automatically pre-fetches the page data in the background. The transition feels instantaneous, almost like a single-page app—because, well, it is!

The Finishing Touches: Loading & Error States

So, what happens during that 500ms delay we added to our data fetching function? Right now... nothing. The user just kind of waits. We can do better than that.

The App Router has this almost magical convention for loading states. If you create a file named loading.tsx inside a folder, Next.js will automatically show it while the content of page.tsx is loading.

Let's add one for our blog posts. Create app/blog/[slug]/loading.tsx:

// app/blog/[slug]/loading.tsx
export default function BlogPostLoading() {
  return (
    <main className="p-24 animate-pulse">
      <div className="h-10 bg-gray-300 rounded w-3/4 mb-6"></div>
      <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
      <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
      <div className="h-4 bg-gray-200 rounded w-5/6"></div>
    </main>
  );
}

That's it. No, really. Just by creating that file, Next.js will now show this loading skeleton while getPostData is running in your page.tsx. This is all handled on the server with React Suspense under the hood, but you barely have to think about it.

Similarly, you can handle errors with an error.tsx file. This component must be a client component, because it receives functions to try and recover from the error.

Create app/blog/[slug]/error.tsx:

// app/blog/[slug]/error.tsx
'use client';

export default function BlogPostError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <main className="p-24 text-center">
      <h2 className="text-2xl font-bold text-red-500">Something went wrong!</h2>
      <p className="mt-2">{error.message}</p>
      <button
        className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
        onClick={() => reset()}
      >
        Try again
      </button>
    </main>
  );
}

This file automatically creates an error boundary around your page.tsx. If getPostData were to throw an error, this UI would be displayed instead of crashing the whole app. It's incredibly robust.

Where to Go From Here?

Phew. We've covered a lot. From routing and layouts to the server/client component model, data fetching, and even loading and error states. This is truly the core foundation of building with the Next.js App Router.

Honestly, the best way to make all of this stick is to just build stuff. Don't be afraid to experiment and break things. The mental model takes a little getting used to, I won't lie, but once it clicks, you'll feel like you have superpowers.

If you're ready for the next step, I'd suggest exploring Next.js Server Actions for handling form submissions without having to write separate API endpoints. It's another mind-blowing feature of this new paradigm.

You've got this. Welcome to the new era of Next.js.

Frequently Asked Questions

Can I still use the pages router?

Yep, you absolutely can! You can even use both the app and pages routers in the same project, which is great for a gradual migration. That said, for any new projects, the Vercel team strongly recommends starting with the App Router. It really is the future of the framework.

When should I use a Server Component vs. a Client Component?

My rule of thumb is to start by making everything a Server Component. Then, for each component, I ask myself: "Does this need interactivity (like onClick or onChange)? Does it need to use state (useState) or lifecycle effects (useEffect)? Does it need browser-only APIs (window, localStorage)?" If the answer to any of those is yes, that's your cue to add 'use client'; at the top. Otherwise, keep it on the server for the performance win.

How do I handle SEO and metadata in the App Router?

It's actually easier than ever! You can export a metadata object or a generateMetadata function directly from your page.tsx or layout.tsx files. Next.js will automatically take care of generating the correct <head> tags. We didn't cover it in detail here, but the official documentation on metadata is excellent and straightforward.

Is getStaticProps and getServerSideProps gone?

In the App Router, yes, they are. The new data fetching model with async/await directly in Server Components replaces them. You can now control caching behavior (to achieve the same results as SSG or SSR) using the options in the native fetch API. It's a much more flexible and unified approach, in my opinion.

Related Articles