File-Based Routing in Next.js: Creating Pages & Understanding Routes

Step 2 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

By the end of this step you will have three new pages in your application: /posts, /posts/new, and /login. You will understand exactly how Next.js maps folders to URLs and how the not-found page works.

Goal: Navigate between multiple pages by typing URLs in the browser.


Table of Contents

  1. Recap: Where We Are
  2. Core Concept: File-Based Routing in Depth
  3. Create the Posts Page
  4. Create the New Post Page (Nested Route)
  5. Create the Login Page
  6. Core Concept: The not-found Page
  7. Create a Custom 404 Page
  8. Core Concept: Per-Page Metadata
  9. Test All Your Routes
  10. Summary & Key Takeaways

Recap: Where We Are

↑ Index

After Step 1, your project has this structure:

app/
├── globals.css
├── layout.tsx       ← Root Layout (wraps everything)
└── page.tsx         ← Home page at "/"

You have one page. In this step, we will add more pages and see how the App Router handles them.


Core Concept: File-Based Routing in Depth

↑ Index

In plain React, you define routes in code:

// Plain React with React Router
<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/posts" element={<Posts />} />
  <Route path="/posts/new" element={<NewPost />} />
  <Route path="/login" element={<Login />} />
</Routes>

In Next.js App Router, you never write route configuration. Instead, you create a folder and put a page.tsx inside it. That's it — the folder path becomes the URL path.

Here is the mental model:

app/
├── page.tsx                → /
├── posts/
│   ├── page.tsx            → /posts
│   └── new/
│       └── page.tsx        → /posts/new
└── login/
    └── page.tsx            → /login

The Rules

  1. Only page.tsx creates a route. If a folder has no page.tsx, it does not create a URL — it's just an organizational folder.

  2. Nesting creates path segments. app/posts/new/page.tsx maps to /posts/new because posts and new are nested folders inside app.

  3. Each page.tsx must have a default export. The default export is the React component that renders for that URL.

  4. Folders can contain other files. You can put components, utilities, and styles in the same folder as page.tsx. Only page.tsx (and other special files like layout.tsx, not-found.tsx, error.tsx, … which we will cover later) are picked up by the router.

Naming convention — files and folders: The file must be page.tsx (lowercase), not Page.tsx. Folder names should also be lowercase (e.g. posts/, not Posts/). Next.js only recognises the exact lowercase names for its special files.

Naming convention — component functions: The exported function component inside page.tsx must start with a capital letter and follow PascalCase (e.g. PostsPage, LoginPage, HomePage). This is a core React rule: a function whose name starts with a lowercase letter is treated as a plain HTML tag, not a React component. React only recognises a JSX element as a component when its name begins with an uppercase letter — so <postsPage /> would be treated as an unknown HTML element, while <PostsPage /> is correctly treated as a React component.

Why file-based routing? It eliminates an entire category of bugs — routing misconfiguration. The URL structure is visible just by looking at your file tree. No import chains, no route arrays, no typos in path strings.


Create the Posts Page

↑ Index

This page will eventually show a list of blog posts. For now, we create it with placeholder content so we can see the routing in action.

Create the file app/posts/page.tsx:

export default function PostsPage() {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white shadow-sm">
        <div className="mx-auto max-w-4xl px-6 py-4">
          <h1 className="text-2xl font-bold text-gray-900">Superblog</h1>
        </div>
      </header>

      <main className="mx-auto max-w-4xl px-6 py-12">
        <h2 className="mb-4 text-3xl font-bold text-gray-900">All Posts</h2>
        <p className="mb-8 text-gray-600">
          Posts will appear here once we connect to the database.
        </p>

        <div className="rounded-lg bg-white p-6 shadow-md">
          <p className="italic text-gray-400">
            No posts yet. Check back after Step 8!
          </p>
        </div>
      </main>
    </div>
  )
}

What to notice:

  • The file is at app/posts/page.tsx. This means the URL /posts now exists in your application. Just visit /posts in the browser.
  • This is a Server Component (no "use client"). The HTML is generated on the server and sent to the browser.
  • We are duplicating the header the same as the home page / — this is intentional. In Step 3, we will extract it into a shared layout to avoid this repetition. For now, seeing the duplication teaches you why layouts exist.

Create the New Post Page (Nested Route)

↑ Index

This page will later have a form to create a blog post. For now, it's a placeholder.

Create the file app/posts/new/page.tsx:

export default function NewPostPage() {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white shadow-sm">
        <div className="mx-auto max-w-4xl px-6 py-4">
          <h1 className="text-2xl font-bold text-gray-900">Superblog</h1>
        </div>
      </header>

      <main className="mx-auto max-w-4xl px-6 py-12">
        <h2 className="mb-4 text-3xl font-bold text-gray-900">
          Create New Post
        </h2>
        <p className="mb-8 text-gray-600">
          The form to create a post will be here once we add Server Actions in
          Step 15.
        </p>

        <div className="rounded-lg bg-white p-6 shadow-md">
          <div className="space-y-4">
            <div>
              <label className="mb-1 block text-sm font-medium text-gray-700">
                Title
              </label>
              <div className="h-10 w-full rounded border border-gray-300 bg-gray-100" />
            </div>
            <div>
              <label className="mb-1 block text-sm font-medium text-gray-700">
                Content
              </label>
              <div className="h-32 w-full rounded border border-gray-300 bg-gray-100" />
            </div>
            <button
              disabled
              className="cursor-not-allowed rounded bg-gray-300 px-4 py-2 text-gray-500"
            >
              Create Post (coming soon)
            </button>
          </div>
        </div>
      </main>
    </div>
  )
}

What is a nested route?

app/posts/new/page.tsx creates the URL /posts/new. Notice how the folder structure mirrors the URL:

app/
└── posts/           ← "/posts" segment
    ├── page.tsx     ← renders at /posts
    └── new/         ← "/new" segment
        └── page.tsx ← renders at /posts/new

/posts and /posts/new are two separate pages. They share the posts path segment, but they have completely independent page.tsx files. Visiting /posts does not render /posts/new and vice versa.

Key insight — folders = URL segments, nothing more. Each folder you nest adds one segment to the URL. posts/new/ inside app/ becomes /posts/new. That is the only thing nesting does here — it builds the URL path.

It does not mean that /posts/new is rendered inside /posts, or that the two pages share any UI. They are completely independent pages. The folder relationship is purely about the URL shape.

Shared UI (like a navigation bar that wraps both pages) comes from layout.tsx, not from folder nesting — we cover that in Step 3.


Create the Login Page

↑ Index

Create the file app/login/page.tsx:

export default function LoginPage() {
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50">
      <div className="w-full max-w-md rounded-lg bg-white p-8 shadow-md">
        <h2 className="mb-2 text-center text-2xl font-bold text-gray-900">
          Sign In
        </h2>
        <p className="mb-6 text-center text-gray-500">
          Authentication will be added in Step 12.
        </p>

        <div className="space-y-4">
          <div>
            <label className="mb-1 block text-sm font-medium text-gray-700">
              Email
            </label>
            <div className="h-10 w-full rounded border border-gray-300 bg-gray-100" />
          </div>
          <div>
            <label className="mb-1 block text-sm font-medium text-gray-700">
              Password
            </label>
            <div className="h-10 w-full rounded border border-gray-300 bg-gray-100" />
          </div>
          <button
            disabled
            className="w-full cursor-not-allowed rounded bg-gray-300 py-2 text-gray-500"
          >
            Sign In (coming soon)
          </button>
        </div>

        <p className="mt-4 text-center text-sm text-gray-500">
          Don&apos;t have an account?{' '}
          <span className="text-blue-500">Register</span>
        </p>
      </div>
    </div>
  )
}

Your folder structure should now look like this:

app/
├── globals.css
├── layout.tsx          ← Root Layout
├── page.tsx            ← /
├── login/
│   └── page.tsx        ← /login
└── posts/
    ├── page.tsx        ← /posts
    └── new/
        └── page.tsx    ← /posts/new

Four folders, four URLs. That is all there is to routing in Next.js.


Core Concept: The not-found Page

↑ Index

What happens if you visit a URL that does not exist, like /settings?

Try it: start your dev server with pnpm dev and visit http://localhost:3000/settings.

Next.js shows a default 404 page. This is built-in — you do not need to configure it.

But you can customize it. Next.js looks for a special file called not-found.tsx in the app/ directory. If it exists, Next.js uses your custom component instead of the default one.

Special files in the App Router:

FileWhen it renders
page.tsxWhen the URL matches this folder
layout.tsxWraps page.tsx and child routes
not-found.tsxWhen no matching route is found (404)
loading.tsxWhile a page is loading (we'll use this later)
error.tsxWhen an error occurs during rendering

These are called file conventions. Next.js recognizes them by name and assigns them special behavior. Any other file name (like utils.ts or MyComponent.tsx) is ignored by the router.


Create a Custom 404 Page

↑ Index

Create the file app/not-found.tsx:

import Link from 'next/link'

export default function NotFound() {
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50">
      <div className="text-center">
        <h1 className="mb-4 text-6xl font-bold text-gray-300">404</h1>
        <h2 className="mb-2 text-2xl font-semibold text-gray-800">
          Page Not Found
        </h2>
        <p className="mb-6 text-gray-500">
          The page you are looking for does not exist.
        </p>
        <Link
          href="/"
          className="inline-block rounded bg-blue-500 px-6 py-2 text-white transition hover:bg-blue-600"
        >
          Go Home
        </Link>
      </div>
    </div>
  )
}

What is new here?

We import Link from "next/link". This is the first time we use it. Let's understand it:

In plain React with React Router, you use <Link to="/about">. In Next.js, you use <Link href="/">.

import Link from 'next/link'
;<Link href="/">Go Home</Link>

Why use Link instead of an <a> tag?

  • <a href="/"> — performs a full page reload. The browser throws away everything, requests the HTML again, re-downloads CSS/JS, and re-renders from scratch.
  • <Link href="/"> — performs a client-side navigation. Next.js only re-renders the part of the page that actually changes (the page.tsx content) and leaves the rest untouched. The surrounding layout stays mounted, any client-side state is preserved, and the transition feels instant — no white flash, no full reload.

This is similar to React Router's <Link>, but built into Next.js. You never need to install a routing library.

Important: We use Link here just for the 404 page's "Go Home" button. In Step 3, we will use it extensively for navigation between all pages.


Core Concept: Per-Page Metadata

↑ Index

In Step 1, we set metadata in layout.tsx and it applied to all pages. But each page can override it.

Let's add metadata to our new pages. Update each file by adding the metadata export before the default export function.

Update app/posts/page.tsx

Add this at the top of the file, before the export default function:

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "All Posts — Superblog",
  description: "Browse all blog posts on Superblog",
};

export default function PostsPage() {
  // ... rest stays the same

Update app/posts/new/page.tsx

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Create New Post — Superblog",
  description: "Write and publish a new blog post",
};

export default function NewPostPage() {
  // ... rest stays the same

Update app/login/page.tsx

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "Sign In — Superblog",
  description: "Sign in to your Superblog account",
};

export default function LoginPage() {
  // ... rest stays the same

How metadata inheritance works:

  1. layout.tsx defines title: "Superblog" — this is the default for all pages.
  2. When you visit /posts, Next.js finds the metadata export in app/posts/page.tsx and uses title: "All Posts — Superblog" instead.
  3. When you visit /, there is no metadata in app/page.tsx, so it falls back to the layout's title: "Superblog".

Check the browser tab title as you navigate — it changes per page!

But Wait — Where is the <head> Tag?

If you look at layout.tsx, you will notice something:

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body> {/* ← There is no <head> here! */}
    </html>
  )
}

There is <html> and <body>, but no <head> tag. So how do <title> and <meta> tags end up in the page?

The answer: Next.js generates <head> automatically from your metadata exports.

Here is what happens behind the scenes when a user visits /posts:

  1. Next.js finds the page at app/posts/page.tsx
  2. It reads the metadata export: { title: "All Posts — Superblog", description: "..." }
  3. It also reads the metadata from the parent layout.tsx: { title: "Superblog", description: "..." }
  4. It merges them (the page's metadata wins over the layout's)
  5. It automatically generates a <head> tag with the result:
<html lang="en">
  <head>
    <!-- Next.js generates this entire block for you -->
    <title>All Posts — Superblog</title>
    <meta name="description" content="Browse all blog posts on Superblog" />
    <!-- plus other tags: charset, viewport, favicon, etc. -->
  </head>
  <body>
    <!-- your page content -->
  </body>
</html>

Why not just write <head> manually? You could try this in the layout:

// ❌ This approach has problems
<html>
  <head>
    <title>Superblog</title>
  </head>
  <body>{children}</body>
</html>

But then the title would be "Superblog" on every page. The /posts page cannot reach up and change the <title> inside the layout's <head> — the layout and the page are separate components.

The metadata export solves this by letting each page declare its own metadata independently. Next.js collects all the declarations, merges them, and builds the final <head> for you. It is a framework feature — you declare what you want, and Next.js handles where to put it.

Think of it this way: In plain React, you control the HTML yourself. In Next.js, the framework owns the <head> tag. You communicate with it through the metadata export. This is a trade-off: you lose direct control, but you gain automatic merging, deduplication, and server-side generation.


Test All Your Routes

↑ Index

Start the dev server if it is not running:

pnpm dev

Visit each URL and verify:

URLWhat you should seeBrowser tab title
http://localhost:3000Superblog home page with feature cards"Superblog"
http://localhost:3000/posts"All Posts" placeholder page"All Posts — Superblog"
http://localhost:3000/posts/new"Create New Post" with disabled form mockup"Create New Post — Superblog"
http://localhost:3000/loginCentered sign-in card with disabled form"Sign In — Superblog"
http://localhost:3000/anythingCustom 404 page with "Go Home" link"Superblog"

Click the "Go Home" link on the 404 page — it should navigate instantly to / without a full page reload.

Your final folder structure:

app/
├── globals.css
├── layout.tsx              ← Root Layout
├── not-found.tsx           ← Custom 404 page
├── page.tsx                ← /
├── login/
│   └── page.tsx            ← /login
└── posts/
    ├── page.tsx            ← /posts
    └── new/
        └── page.tsx        ← /posts/new

Summary & Key Takeaways

↑ Index

ConceptWhat it means
File-based routingEach folder with a page.tsx inside app/ becomes a URL route
Nested routesapp/posts/new/page.tsx/posts/new — folders create path segments
File conventionspage.tsx, layout.tsx, not-found.tsx, loading.tsx, error.tsx are special
not-found.tsxCustom 404 page — shown when no route matches
Link componentClient-side navigation — instant transitions without full page reload
Per-page metadataEach page.tsx can export metadata to override the layout's defaults
Metadata inheritancePages inherit metadata from parent layouts; pages can override specific fields

What you might have noticed: We are duplicating the header across every page. That is annoying and error-prone. This is exactly the problem that layouts solve — and that is what Step 3 is about.

What is Next

In Step 3, we will extract the repeated header into a shared layout, add a proper navigation bar with Link components, and learn the difference between Server Components and Client Components in action.