Build an Airbnb-Style App with Next.js

Build a rental listing app using Next.js App Router and TypeScript.

Introduction

30 Second Summary

Think about the last time you browsed a rental listing site like Airbnb. You clicked a property card, landed on a detail page, and maybe started filling in dates for a booking. Each of those pages had a unique URL, but the navigation bar at the top never disappeared.

In this project, you will build a rental property listing app called StayFinder using Next.js. You will create multiple pages that map directly to folders in your project, so by the end you will intuitively understand how modern web routing works just by looking at a file tree.

What You'll Build

You will have a working multi-page app where you can browse property listings on a homepage, click into any listing to see its full details, and fill out a booking form. Every page you visit corresponds to a folder you created.

By the end of this project, you'll have:

  • A live homepage with a grid of clickable property cards that each navigate to their own detail page, all powered by a single template file.
  • A persistent navigation bar that stays visible across every page in your app without you writing it more than once.
  • A booking confirmation flow with a form for dates and guest count, nested deep in the URL hierarchy to show how folder nesting maps to URL nesting.
  • Secret Mission: Add a skeleton loading state and a custom 404 page using special Next.js files that slot into your folder structure automatically.

Are there any prerequisites?

None. This guide walks you through installing everything you need, including Node.js and your code editor. Basic familiarity with HTML is helpful but not required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to articulate the goal now makes every step that follows feel purposeful.

Set Up the Project

To build StayFinder, you need a working Next.js project with the right tools configured. Without a scaffolded project, there is nothing to run or see in the browser.

In this step, you will scaffold a Next.js app, confirm it runs locally, and push it to GitHub so every change you make from here on is tracked.

In this step, get ready to:
  • Scaffold a Next.js project with TypeScript and Tailwind CSS.
  • Push the initial code to a GitHub repository.
  • Explore the project structure in Cursor.
Scaffold the Next.js project

Next.js needs Node.js to run. Let's confirm your installation is ready before scaffolding the project.

  • Open a terminal in Cursor by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Confirm Node.js is installed by running this command:
node -v

You should see a version number like v24.18.0 or similar. Any version v20.9 or higher works.

Don't see a version number?

If you see an error like "command not found" or "not recognized", Node.js is not installed or not in your system PATH.

  • Download and install Node.js LTS from nodejs.org.
  • On Windows, run the .msi installer and follow the prompts. Restart your terminal after installing.
  • help me install Node.js for my operating system

Now you will create the StayFinder project folder on your Desktop.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Next.js project by running this command:
npx create-next-app@latest stayfinder

What will this command ask?

The CLI will ask: "Would you like to use the recommended Next.js defaults?" You will see three options.

  • Select Yes, use recommended defaults (the first option).
  • This gives you TypeScript, ESLint, Tailwind CSS, and App Router automatically.

After you select the defaults, the CLI installs all dependencies. This takes 30 to 60 seconds depending on your internet connection.

  • Move into your new project folder and start the development server by running these commands:
cd stayfinder
npm run dev

Before you open the browser, what do you think you will see at the URL? A blank page, an error, or something else?

You should see the default Next.js starter page with the Next.js logo and links to documentation. This confirms your project is running.

Page not loading?

  • Check your terminal for error messages. If you see "port 3000 already in use", another app is using that port. Stop it or try npm run dev -- -p 3001.
  • Make sure you ran cd stayfinder before npm run dev. Running the command from the wrong folder causes a "missing package.json" error.
  • help me debug why my Next.js dev server is not starting
Create a GitHub repository and push

The scaffolding command already initialized a Git repository and made a first commit for you. Now you need a remote repository on GitHub to push your code to.

  • Go to github.com in your browser.
  • Click the + icon in the top-right corner, then click New repository.
  • Enter stayfinder as the repository name.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click Create repository.

GitHub shows a quick setup page with commands. You will use your own terminal instead.

  • Switch back to your terminal in Cursor (the dev server is still running). Open a second terminal by clicking the + icon in the terminal panel.
  • Connect your local project to GitHub by running these commands:
git remote add origin https://github.com/[[GITHUB_USERNAME="YOUR_USERNAME"]]/stayfinder.git
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin tells Git where to push your code (the GitHub URL you just created).
  • git branch -M main renames your default branch to main (GitHub's expected name).
  • git push -u origin main uploads your code to GitHub and sets the tracking relationship.
  • Refresh your GitHub repository page in the browser.

You should see all your project files listed on the repository page. The initial commit from the scaffold appears in the commit history.

Push rejected or authentication failed?

  • If you see "authentication failed", you may need to set up a personal access token or SSH key for GitHub. GitHub no longer accepts passwords for command-line pushes.
  • If you see "remote origin already exists", the scaffold may have set a different remote. Run git remote set-url origin followed by your repository URL to fix it.
  • help me push my code to GitHub
Explore the scaffold in Cursor

Your project is running and on GitHub. Now take a moment to understand what the scaffold created for you.

  • In Cursor, click File in the top menu bar.
  • Click Open Folder.
  • Select the stayfinder folder on your Desktop, then click Open.

Look at the Explorer sidebar on the left. Here are the key files and folders to know:

  • app/page.tsx is the homepage. This file controls what you see at http://localhost:3000.
  • app/layout.tsx is the root layout. It wraps every page in your app with shared HTML structure.
  • app/globals.css contains your Tailwind CSS import.
  • public/ holds static files like images and icons.
  • Config files at the root (package.json, tsconfig.json, next.config.ts) manage dependencies, TypeScript settings, and Next.js options.

What is the app/ folder?

In Next.js App Router, the app/ folder IS your website. Every folder you create inside it becomes a URL path that visitors can navigate to.

This is called file-system routing. You will see this pattern in action throughout the project as you add new pages just by creating new folders.

Your environment is ready and your code is tracked on GitHub. Next up, you will replace the default starter page with your own listings homepage.

Build the Listings Homepage

Right now your homepage is the default Next.js placeholder page. Replacing it with your own content is the fastest path to seeing YOUR app in the browser.

In this step, you'll create a data model for rental listings and build a property card grid that displays titles, locations, prices, and ratings.

In this step, get ready to:
  • Create TypeScript types and a listings data file.
  • Replace the default homepage with a property card grid.
  • Commit and push your changes to GitHub.
Create your TypeScript types and data

Before you build the UI, you need the data that powers it. You'll create a shared data file that lives outside the app/ folder so it never becomes a URL route.

  • In Cursor's file explorer sidebar, right-click the project root (the stayfinder folder) and select New Folder.
  • Name the folder lib.
  • Right-click the new lib folder and select New File. Name it data.ts.

Why does lib/ live outside app/?

The app/ folder IS your website. Every folder inside it can become a URL path. The lib/ folder sits at the project root alongside app/, so Next.js never turns it into a route.

It is just shared code that any page can import.

  • Add the type definitions for your listings by pasting this code into lib/data.ts:
export type Listing = {
  id: string;
  title: string;
  location: string;
  price: number;
  rating: number;
  description: string;
  guests: number;
  bedrooms: number;
  bathrooms: number;
};

export type Booking = {
  listingId: string;
  checkIn: string;
  checkOut: string;
  guests: number;
};

What do these types do?

A TypeScript type defines the shape of your data. Listing describes every property a rental listing must have (id, title, price, etc.).

Booking describes a reservation. You won't use it yet, but defining it now means your data model is ready for the booking page later.

  • Below the types, add the first two hardcoded listings to your array:
export const listings: Listing[] = [
  {
    id: "1",
    title: "Cozy Mountain Cabin",
    location: "Aspen, Colorado",
    price: 150,
    rating: 4.9,
    description:
      "A warm and inviting cabin nestled in the mountains. Perfect for a weekend getaway with stunning views of the surrounding peaks and a cozy fireplace.",
    guests: 4,
    bedrooms: 2,
    bathrooms: 1,
  },
  {
    id: "2",
    title: "Beachfront Villa",
    location: "Malibu, California",
    price: 350,
    rating: 4.8,
    description:
      "Wake up to the sound of waves in this stunning beachfront villa. Floor-to-ceiling windows offer panoramic ocean views and private beach access.",
    guests: 6,
    bedrooms: 3,
    bathrooms: 2,
  },
  • Continue the array by pasting the last two listings and the helper function directly below:
  {
    id: "3",
    title: "Downtown Loft",
    location: "New York, New York",
    price: 200,
    rating: 4.7,
    description:
      "A stylish loft in the heart of Manhattan. Walk to restaurants, theaters, and iconic landmarks. Perfect for a city adventure.",
    guests: 2,
    bedrooms: 1,
    bathrooms: 1,
  },
  {
    id: "4",
    title: "Lakeside Retreat",
    location: "Lake Tahoe, Nevada",
    price: 275,
    rating: 4.9,
    description:
      "Escape to this peaceful lakeside home. Enjoy kayaking, hiking, and evenings by the fire pit with panoramic mountain views.",
    guests: 8,
    bedrooms: 4,
    bathrooms: 3,
  },
];

export function getListingById(id: string): Listing | undefined {
  return listings.find((listing) => listing.id === id);
}

What does getListingById do?

This helper function searches the listings array for a listing whose id matches the one you pass in. You'll use it later when building individual listing pages.

It returns Listing | undefined because the ID might not match anything. TypeScript forces you to handle that case.

  • Save lib/data.ts by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm the file exists by checking Cursor's sidebar. You should see the lib folder with data.ts inside it.

Don't see the lib folder in the sidebar?

Make sure you created lib at the project root (same level as app/), not inside app/. If it ended up in the wrong place, drag it to the correct level in the sidebar.

help me fix my lib folder location

Replace the default homepage

Now you have data. The next step is to display it. In Next.js, the file app/page.tsx controls what the user sees at the root URL (/).

  • Open app/page.tsx in Cursor.
  • Select all the existing content (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
  • Replace it with this component that renders a grid of property cards:
import Link from "next/link";
import { listings } from "@/lib/data";

export default function HomePage() {
  return (
    <div>
      {/* Page heading and subtitle */}
      <h1 className="mb-2 text-3xl font-bold">Find your next stay</h1>
      <p className="mb-8 text-gray-600">
        Browse unique stays around the world
      </p>

      {/* Responsive grid: 1 col on mobile, 2 on tablet, 3 on desktop */}
      <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
        {listings.map((listing) => (
          <div
            key={listing.id}
            className="group block overflow-hidden rounded-xl border border-gray-200 transition-shadow hover:shadow-lg"
          >
            {/* Placeholder image gradient */}
            <div className="h-48 bg-gradient-to-br from-rose-100 to-rose-200" />
            <div className="p-4">
              <div className="flex items-start justify-between">
                <h2 className="font-semibold text-lg">{listing.title}</h2>
                <span className="text-sm">{"\u2B50"} {listing.rating}</span>
              </div>
              <p className="text-sm text-gray-500">{listing.location}</p>
              <p className="mt-2 font-semibold">
                ${listing.price}{" "}
                <span className="font-normal text-gray-500">/ night</span>
              </p>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

What does this component do?

  • It imports the listings array from your data file using the @/lib/data path alias (@/ points to your project root).
  • listings.map() loops through all 4 listings and renders a card for each one.
  • Each card shows a gradient placeholder for the image, the title, star rating, location, and price per night.
  • The Link import is not used yet. You'll wire it up in the next step to make cards clickable.

Before you check the browser, what do you think you'll see at http://localhost:3000?

  • Save app/page.tsx and switch to your browser.
  • Refresh http://localhost:3000. You should see a heading "Find your next stay", a subtitle, and a grid of 4 property cards showing titles, locations, prices, and star ratings.

Seeing an error instead of cards?

  • Check that lib/data.ts is at the project root (same level as app/), not inside app/. The import path @/lib/data expects it there.
  • Make sure your dev server is still running. If it stopped, run npm run dev again in your terminal.
  • Verify there are no red squiggly lines in Cursor indicating a typo in either file.

help me debug my Next.js homepage not rendering

✔️ Awesome, I've got everything!

Great. Double check you've saved both files.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these chunks in lib/data.ts, in this order:

  • The Listing type (9 properties) and Booking type (4 properties).
  • The listings array with 4 objects (Cozy Mountain Cabin, Beachfront Villa, Downtown Loft, Lakeside Retreat).
  • The getListingById helper function.

For app/page.tsx, confirm you have:

  • Imports for Link and listings at the top.
  • A HomePage function that returns an h1, a subtitle p, and a grid div mapping over listings.
  • Each card rendered as a div (not a Link yet) with title, rating, location, and price.
Commit and push your changes
  • Stage, commit, and push your changes by running these commands in your terminal:
git add .
git commit -m "feat: add listings homepage with property cards"
git push
  • Refresh your GitHub repository page in the browser. You should see a second commit with the message "feat: add listings homepage with property cards".

Push rejected or authentication error?

  • If you see "permission denied" or "authentication failed", make sure you're signed in to GitHub in your terminal. Run git remote -v to confirm the remote URL matches your repository.
  • If the push is rejected because of "non-fast-forward", run git pull --rebase origin main first, then try pushing again.

help me push my code to GitHub

Your homepage now shows real content. Next up, you'll create individual listing detail pages so users can click a card and see the full property information.

Create Dynamic Listing Detail Pages

Your homepage shows 4 listing cards, but clicking one does nothing. You need individual pages for each listing to show full details like descriptions, amenity counts, and a booking link.

Creating 4 separate files would be tedious and impossible to scale. Dynamic routes solve this by using a single file template that generates a unique page for every listing ID in the URL.

In this step, get ready to:
  • Create a dynamic route page that renders any listing's full details from a single template file.
  • Make the homepage cards clickable links that navigate to each listing's detail page.
  • Commit and push to GitHub.
Create the dynamic route folder and detail page

In Next.js, a folder name wrapped in square brackets (like [id]) becomes a dynamic route segment. One page.tsx inside that folder handles every possible value in the URL.

  • In Cursor's file explorer, create a new folder path inside app/: listings/[id]/. The square brackets are literal characters in the folder name.
  • Inside app/listings/[id]/, create a new file called page.tsx.
  • Paste the following code into page.tsx to set up the data-fetching layer:
import Link from "next/link";
import { notFound } from "next/navigation";
import { getListingById } from "@/lib/data";

export default async function ListingPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  // Extract the listing ID from the URL
  const { id } = await params;
  const listing = getListingById(id);

  // Show a 404 page if the listing doesn't exist
  if (!listing) {
    notFound();
  }
}

What does this code do?

This component is an async function because in Next.js 16, the params prop is a Promise. You must await it to get the actual URL values.

  • await params extracts the id from the URL (e.g., visiting /listings/2 gives id = "2").
  • getListingById(id) looks up the listing in your data array.
  • notFound() triggers a 404 page if someone visits an ID that doesn't exist (like /listings/999).
  • Still inside the function body, below the notFound() check, add the return statement that renders the listing details:
  return (
    <div>
      <div className="mb-6 h-64 rounded-xl bg-gradient-to-br from-rose-100 to-rose-300" />

      <div className="flex items-start justify-between mb-4">
        <div>
          <h1 className="text-2xl font-bold">{listing.title}</h1>
          <p className="text-gray-500">{listing.location}</p>
        </div>
        <div className="text-right">
          <p className="text-2xl font-bold">${listing.price}</p>
          <p className="text-sm text-gray-500">per night</p>
        </div>
      </div>

      <div className="mb-6 flex gap-4 text-sm text-gray-600">
        <span>{listing.guests} guests</span>
        <span>{listing.bedrooms} bedrooms</span>
        <span>{listing.bathrooms} bathrooms</span>
        <span>{"\u2B50"} {listing.rating}</span>
      </div>

      <p className="mb-8 text-gray-700">{listing.description}</p>

      <Link
        href={`/listings/${listing.id}/book`}
        className="inline-block rounded-lg bg-rose-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-rose-600"
      >
        Reserve this stay
      </Link>
    </div>
  );

What does the return statement render?

  • A gradient placeholder where a property photo would go.
  • The listing title, location, and price arranged in a two-column header.
  • Amenity counts (guests, bedrooms, bathrooms) and the star rating in a row.
  • The full description text.
  • A Link to the booking page at /listings/${listing.id}/book (you will build this page in a later step).
  • Save app/listings/[id]/page.tsx.
  • In your browser, navigate directly to http://localhost:3000/listings/1. You should see the full details for "Cozy Mountain Cabin" with the title, location, price, amenities, description, and a rose-colored "Reserve this stay" button.

Seeing a 404 or blank page?

  • Double-check that your folder is named exactly [id] with literal square brackets. If you named it id without brackets, Next.js treats it as a static route.
  • Make sure the file is named page.tsx (not Page.tsx or index.tsx). Next.js only recognizes lowercase page.tsx as a route file.
  • Confirm the function is marked async and that you have await params (not just params.id directly). Without await, the id will be undefined.

Still stuck? Help me debug my dynamic route page in Next.js. I'm visiting /listings/1 but seeing a 404 or blank page.

Make homepage cards clickable

The detail page works when you type the URL directly, but there is no way to reach it from the homepage. You need to wrap each card in a clickable link.

  • Switch back to app/page.tsx in Cursor.
  • Inside the .map() callback, find the opening <div tag that wraps each card:
          <div
            key={listing.id}
            className="group block overflow-hidden rounded-xl border border-gray-200 transition-shadow hover:shadow-lg"
          >
  • Replace <div with <Link and add the href prop. Also change the closing </div> to </Link>. The updated tag should look like this:
          <Link
            key={listing.id}
            href={`/listings/${listing.id}`}
            className="group block overflow-hidden rounded-xl border border-gray-200 transition-shadow hover:shadow-lg"
          >

Why Link instead of an anchor tag?

The Link component from next/link enables client-side navigation. When a user clicks a Link, Next.js fetches only the new page's content without a full browser reload. The page transition feels instant.

  • Save app/page.tsx.
  • Return to your browser at http://localhost:3000.

Before you click a card, think about what you expect to see. Will there be a way to navigate back to the homepage from the detail page?

  • Click any listing card. The browser navigates to the detail page (e.g., /listings/1) and shows the full listing information.

You see the listing details, but there is no navigation bar or header on the page. The only way back is the browser's back button. That gap is intentional. The next step fixes it.

Cards not clickable?

  • Make sure you changed BOTH the opening <div to <Link AND the closing </div> to </Link>. Mismatched tags cause a compile error.
  • Check that Link is already imported at the top of the file (import Link from "next/link"). It was added by the previous step's scaffold.

Need a hand? Help me figure out why my listing cards are not clickable on the homepage.

✔️ Awesome, I've got everything!

Great. Double check you have saved both app/listings/[id]/page.tsx and app/page.tsx.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these pieces is in place, in order:

  • app/listings/[id]/page.tsx contains the imports (Link, notFound, getListingById), the async function ListingPage with await params, the notFound() guard, and the full return statement rendering the listing details with a "Reserve this stay" Link.
  • app/page.tsx has each card wrapped in <Link href={`/listings/${listing.id}`}> instead of a plain <div>.
Commit and push
  • Commit and push your changes by running these commands in your terminal:
git add .
git commit -m "feat: add dynamic listing detail pages"
git push
  • Refresh your GitHub repo page. You should see a third commit with the message "feat: add dynamic listing detail pages".

Push rejected?

  • If you see a "rejected" error, run git pull --rebase origin main first, then try git push again.

Still having trouble? Help me push my code to GitHub. I'm getting an error when running git push.

Your listings now have dedicated detail pages, all powered by a single template file. Next up, you will fix the navigation problem by adding a persistent header to every page using the root layout.

Add Navigation with the Root Layout

In the last step, you built detail pages for each listing. But try clicking a listing card and then getting back to the homepage. The only way is the browser back button.

The root layout file (layout.tsx) wraps every page in your app automatically. Adding a navigation bar here means it appears on all pages without duplicating code in each one.

In this step, get ready to:
  • Add a persistent navigation header to the root layout.
  • Observe how the layout wraps every page automatically.
  • Commit and push changes to GitHub.
Enhance the root layout with a nav bar

The scaffold created app/layout.tsx with a bare <html> and <body> wrapper. You will replace it with a layout that includes a navigation header and consistent page spacing.

  • In Cursor, open app/globals.css and replace its entire contents with this single line:
@import "tailwindcss";

What does this do?

This imports Tailwind CSS v4 using its CSS-first syntax. The scaffold may have included extra default styles that you do not need. This single import line is all Tailwind requires to enable its utility classes across your app.

  • Now open app/layout.tsx and replace its entire contents with the following imports and metadata:
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";

export const metadata: Metadata = {
  title: "StayFinder",
  description: "Find your perfect stay",
};

What does this code do?

  • Metadata is imported from next to type your page's title and description for browser tabs and search engines.
  • Link is the Next.js component for client-side navigation between pages without a full reload.
  • The ./globals.css import ensures Tailwind's utility classes are available everywhere in your app.
  • Below the metadata, add the RootLayout function that wraps every page with a nav header:
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className="font-sans antialiased">
        <header className="border-b border-gray-200 bg-white">
          <nav className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
            <Link href="/" className="text-xl font-bold text-rose-500">
              StayFinder
            </Link>
            <Link
              href="/"
              className="text-sm text-gray-600 transition-colors hover:text-rose-500"
            >
              Browse
            </Link>
          </nav>
        </header>
        <main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
      </body>
    </html>
  );
}

What does this code do?

  • The <header> contains a <nav> with two links. The "StayFinder" logo links home, and so does the "Browse" link on the right.
  • The <main> element wraps {children} (the current page content) with a max-width and padding so every page has consistent spacing.
  • Because this is the root layout, it automatically wraps every page inside app/. You never need to add the nav to individual pages.
  • Save both app/globals.css and app/layout.tsx.
  • Switch back to your browser at http://localhost:3000. You should see a white header at the top with "StayFinder" in rose/pink on the left and "Browse" on the right.

Don't see the nav bar?

  • Make sure you saved both files. Unsaved changes will not appear in the browser.
  • Check that your app/layout.tsx has import Link from "next/link"; at the top. Without it, the Link components will cause an error.
  • If you see an error in the terminal, check that you did not accidentally leave any leftover code from the scaffold's original layout.

Help me debug my root layout not rendering the nav bar.

Observe layout behavior
  • Click on any listing card to navigate to its detail page.
  • Look at the top of the page. The "StayFinder" navigation bar is still there.
  • Click "StayFinder" or "Browse" in the nav bar to go back to the homepage.

The nav bar appears on every page without you adding it to each one. It never re-renders or flickers during navigation.

Why does the layout persist across pages?

The root app/layout.tsx wraps everything inside the app/ folder. When you navigate between pages, only the {children} portion re-renders. The layout stays mounted.

If you created app/listings/layout.tsx, it would only wrap pages inside app/listings/. Layouts are hierarchical and nest based on folder structure.

Commit and push
  • Return to your terminal and commit the layout changes by running these commands:
git add .
git commit -m "feat: add persistent navigation layout"
git push
  • Refresh your GitHub repo page. You should see a fourth commit with the message feat: add persistent navigation layout.

Push rejected or auth error?

  • If you see a permissions error, make sure you are signed into GitHub in your terminal. Try running the push command again and entering your credentials when prompted.
  • If the push says "everything up to date", your commit may not have saved. Run git status to check for uncommitted changes.

Help me push my code to GitHub.

✔️ Awesome, I've got everything!

Great. Double check that you have saved both files and your browser shows the nav bar on every page.

ⓧ I'd like to double check the full code

Here is the complete app/globals.css:

@import "tailwindcss";

For app/layout.tsx, scroll up and confirm these two blocks are present in order:

  • The imports and metadata block (3 imports, a Metadata object with title "StayFinder").
  • The RootLayout function with a <header> containing two Link components, and a <main> wrapping {children}.

Your app now has a persistent navigation bar and clean page spacing on every route. Next up, you will build the booking flow and see how nested folders create deeper URL paths.

Build the Booking Flow

Your app now has a persistent nav bar that follows the learner everywhere. Clicking StayFinder in the header takes you home from any page.

Users still have no way to actually book a listing. The "Reserve this stay" button on each detail page already points to a URL, but the page doesn't exist yet. In this step you'll build the booking form, and in the process see how folder nesting inside app/ directly maps to deeper URL paths.

In this step, get ready to:
  • Create a nested booking route at app/listings/[id]/book/page.tsx.
  • Review how your folder tree maps to URLs.
  • Commit and push to GitHub.
Create the nested booking route

The detail page already links to /listings/1/book, but visiting that URL shows a 404. You need a page.tsx file inside the matching folder path to bring this route to life.

  • In Cursor's file explorer, create the folder path app/listings/[id]/book/.
  • Create a new file called page.tsx inside the book folder.
  • Paste the following code into app/listings/[id]/book/page.tsx:
import Link from "next/link";
import { notFound } from "next/navigation";
import { getListingById } from "@/lib/data";

export default async function BookingPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const listing = getListingById(id);

  if (!listing) {
    notFound();
  }

  return (
    <div className="mx-auto max-w-2xl">
      <h1 className="mb-6 text-2xl font-bold">Confirm your booking</h1>

      <div className="mb-6 rounded-xl border border-gray-200 p-4">
        <div className="flex gap-4">
          <div className="h-20 w-20 rounded-lg bg-gradient-to-br from-rose-100 to-rose-200" />
          <div>
            <h2 className="font-semibold">{listing.title}</h2>
            <p className="text-sm text-gray-500">{listing.location}</p>
            <p className="mt-1 font-semibold">${listing.price} / night</p>
          </div>
        </div>
      </div>
    </div>
  );
}

What does this code do?

This follows the exact same pattern as your listing detail page. The component is async because params is a Promise in Next.js 16.

  • await params extracts the id from the URL (e.g. 1 from /listings/1/book).
  • getListingById(id) looks up the listing data so you can display the summary card.
  • notFound() shows a 404 if someone navigates to a booking URL for a listing that doesn't exist.
  • Save app/listings/[id]/book/page.tsx.
  • In your browser, navigate to http://localhost:3000/listings/1/book. You should see the heading "Confirm your booking" and a small summary card showing "Cozy Mountain Cabin" with its location and price.

Seeing a 404 instead?

  • Double check your folder structure. The file must be at app/listings/[id]/book/page.tsx with literal square brackets around id.
  • Make sure the file is named exactly page.tsx (lowercase). Only files with this exact name become routes.

Help me debug why my booking page shows a 404.

The page works but it's missing the booking form. Add the form and back link below the summary card by finding the closing </div> just above ); at the bottom of the return statement, and replacing the last three lines of the file with this expanded version:

  • In app/listings/[id]/book/page.tsx, find this line near the bottom:
      </div>
    </div>
  );
}
  • Replace those four lines with the following code, which adds the booking form and a back link before closing the component:
      </div>

      <form className="space-y-4">
        <div>
          <label className="mb-1 block text-sm font-medium text-gray-700">
            Check-in
          </label>
          <input
            type="date"
            className="w-full rounded-lg border border-gray-300 px-3 py-2"
          />
        </div>
        <div>
          <label className="mb-1 block text-sm font-medium text-gray-700">
            Check-out
          </label>
          <input
            type="date"
            className="w-full rounded-lg border border-gray-300 px-3 py-2"
          />
        </div>
        <div>
          <label className="mb-1 block text-sm font-medium text-gray-700">
            Guests
          </label>
          <select className="w-full rounded-lg border border-gray-300 px-3 py-2">
            {Array.from({ length: listing.guests }, (_, i) => (
              <option key={i + 1} value={i + 1}>
                {i + 1} {i === 0 ? "guest" : "guests"}
              </option>
            ))}
          </select>
        </div>

What does this form code do?

  • Two date inputs let the user pick check-in and check-out dates. The browser renders a native date picker.
  • The guest <select> uses Array.from() to dynamically generate options based on listing.guests. A 4-guest listing gets options 1 through 4.
  • Immediately below that code, paste the submit button and closing tags:
        <button
          type="submit"
          className="w-full rounded-lg bg-rose-500 py-3 font-semibold text-white transition-colors hover:bg-rose-600"
        >
          Confirm reservation
        </button>
      </form>

      <Link
        href={`/listings/${listing.id}`}
        className="mt-4 block text-center text-sm text-gray-500 hover:text-gray-700"
      >
        Back to listing
      </Link>
    </div>
  );
}

What does this section do?

  • The submit button uses the same rose color as the rest of the app for visual consistency.
  • The Link component provides a "Back to listing" link using the same listing.id from the URL, so the user can return to the detail page without using the browser back button.
  • Save app/listings/[id]/book/page.tsx.
  • Refresh http://localhost:3000/listings/1/book in your browser. You should now see the full booking form with check-in and check-out date pickers, a guest dropdown, and a pink "Confirm reservation" button. Below the form, a "Back to listing" link appears.

Form not rendering?

  • Check for unclosed JSX tags. Every <div> needs a matching </div>. The outermost wrapper <div className="mx-auto max-w-2xl"> should close after the Link component.
  • Make sure you kept the first </div> that closes the summary card section. The form goes after that closing tag, not inside it.

Help me fix my booking page JSX structure.

✔️ Awesome, I've got everything!

Great. Double check you've saved the file before moving on.

ⓧ I'd like to double check the full code

Your file should contain these sections in order. Scroll up and confirm each chunk is present:

  • Imports (Link, notFound, getListingById).
  • Async BookingPage function with await params and notFound() check.
  • Return with a heading "Confirm your booking" and a listing summary card.
  • A <form> with check-in, check-out, and guests fields plus a submit button.
  • A Link back to /listings/${listing.id}.
Review the full folder structure

Open Cursor's file explorer and look at your app/ folder. The folder tree now maps directly to every URL in your app.

  • app/page.tsx maps to /.
  • app/listings/[id]/page.tsx maps to /listings/1, /listings/2, etc.
  • app/listings/[id]/book/page.tsx maps to /listings/1/book, /listings/2/book, etc.
  • app/layout.tsx wraps ALL of the above pages automatically.

Why does nesting folders work this way?

The App Router uses file-system routing. Every folder inside app/ becomes a URL segment. The book folder sits inside [id], which sits inside listings. That nesting becomes /listings/[id]/book in the browser.

You never write a routing config file. Just create folders and the URLs appear.

Final commit and push

Before you commit, test the full flow from start to finish. What do you think will happen when you click through every page?

  • Navigate to http://localhost:3000 in your browser.
  • Click any listing card to reach its detail page.
  • Click Reserve this stay to reach the booking form.
  • Click Back to listing to return to the detail page.
  • Click StayFinder in the nav bar to return home.

The StayFinder nav bar stays visible on every page throughout the flow. That is your root layout doing its job.

  • Commit and push your changes by running these commands in your terminal:
git add .
git commit -m "feat: add booking confirmation page"
git push
  • Refresh your GitHub repo page. You should now see 5 commits on the main branch, with the most recent message reading "feat: add booking confirmation page".

Push rejected?

  • If you see a "rejected" error, run git pull --rebase origin main first, then try git push again.
  • Make sure you are inside the stayfinder folder when running git commands.

Help me fix my git push error.

Your GitHub repo now tells the story of your app being built step by step, with 5 clean commits. Next up, you'll take on a Secret Mission to handle loading states and missing pages.

Secret mission

Add Loading and Not-Found States

Your app handles the happy path well, but what happens when a user navigates to a listing that does not exist? And what do they see while a page is loading? Use two more Next.js special files to handle these edge cases gracefully.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local stayfinder project folder on your Desktop.
  • GitHub repository stayfinder.
  • Node.js dev server (running process).
  • Node.js runtime installed on your machine.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend StayFinder with more features.

  • Your dev server is still running at http://localhost:3000.
  • Your GitHub repository keeps your commit history safe in the cloud.
  • Node.js remains installed and ready for future projects.

Pause - I'll come back to this later

Stop the running dev server to free up your terminal. Your files and GitHub repo stay intact for when you return.

  • Switch to the terminal where the dev server is running.
  • Press Ctrl+C to stop the server.
  • When you come back, open the stayfinder folder in Cursor and run npm run dev to start again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your local code and GitHub repository permanently.

  • Stop the dev server by pressing Ctrl+C in the terminal.
  • Delete the local project folder by running this command from your Desktop:

macOS

cd ~/Desktop
rm -rf stayfinder

Windows

cd ~\Desktop
Remove-Item -Recurse -Force stayfinder
  • Delete the GitHub repository by going to your stayfinder repo page, clicking Settings, scrolling to the bottom of the General page, and clicking Delete this repository in the Danger Zone section.
  • To uninstall Node.js on Windows, open Settings, select Apps, find Node.js in the list, and click Uninstall. On macOS, remove it with your package manager or delete the binary manually.

That's a Wrap!

That's a Wrap!

Nice work! You just built a multi-page Airbnb-style listing app with Next.js 16 and TypeScript, complete with a clean GitHub repo showing your commit history.

You've learned how to:

  • Built a multi-page app using the App Router file conventions: page.tsx for routes, layout.tsx for shared UI, [id] folders for dynamic segments, and nested folders for nested routes.
  • Used TypeScript types to define your data shape and catch potential errors across components.
  • Practiced a professional Git workflow with meaningful commits pushed to GitHub, building a readable project history.
  • Secret Mission: Added loading.tsx and not-found.tsx special files to handle loading states and invalid URLs.

Ready to quiz yourself?