Build an AEM-Ready Content Hub

Build a Next.js adventure hub with an AEM-ready content adapter.

Introduction

30 Second Summary

Travel details change often, from prices to route highlights. A website becomes harder to maintain when every update is tangled into the page itself.

In this project, you will build NextWork Adventures, a local adventure catalog with Next.js that renders an Adobe Experience Manager (AEM)-shaped Content Fragment response. You will route all content through a typed adapter that preserves a future path to AEM Publish.

What You'll Build

When you run the finished app, you can browse three polished adventure cards, open complete trip pages, and see exactly how the local content maps to a future AEM integration.

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

  • A responsive adventure catalog that turns a typed AEM-shaped response into three styled cards with trip details.
  • Working dynamic adventure routes that open a dedicated page for each trip. Unknown slugs lead to a purposeful not-found screen.
  • An integration-ready content adapter that keeps the UI separate from its content source. A visible panel shows the active source and the persisted-query path /graphql/execute.json/wknd-shared/adventures-all.
  • Secret Mission: Evolve the content model with a featured field so the adapter puts Madeira Trail Circuit first and the catalog displays a Featured badge.

Are there any prerequisites?

You should be comfortable with React and Next.js. No Adobe Experience Manager access is required.

Before We Start

Before any hands-on work, lock in the boundary of this project. You are building a frontend integration simulation that consumes an AEM-shaped response locally while preserving a replaceable path for a future AEM Publish connection.

Set Up the Next.js Workspace

Your AEM Headless integration simulation needs a predictable local home before it can model structured content.

Manual configuration exposes the runtime choice. It also exposes every dependency choice inside your Next.js workspace.

In this step, get ready to:
  • Prepare the aem-content-hub workspace in VS Code.
  • Install the pinned project dependencies with npm.
  • Run the starter page at http://localhost:3000.
Create and configure the workspace

The package.json file pins the project dependencies. The tsconfig.json file defines how TypeScript checks the source code.

  • Press Cmd+Space to open Spotlight.
  • Type Visual Studio Code into the search field.
  • Press Return to open VS Code.
  • Select File in the menu bar.
  • Select Open Folder....

Why open a folder as a workspace?

VS Code treats an opened folder as a workspace. Its integrated terminal starts from that folder.

  • Select Desktop in the folder dialog.
  • Select New Folder.
  • Enter aem-content-hub as the folder name.
  • Press Return to create the folder.
  • Select the aem-content-hub folder.
  • Select Open.
  • Approve the workspace trust prompt if it appears.

You should see aem-content-hub at the top of the Explorer sidebar. The empty workspace is ready for its configuration files.

  • Select New File... in the Explorer toolbar.
  • Enter package.json.
  • Press Return.
  • Add the dependency manifest by pasting this code into package.json:
{"name":"aem-content-hub","version":"0.1.0","private":true,"scripts":{"dev":"next dev","build":"next build","start":"next start"},"dependencies":{"next":"16.3.8","react":"19.3.0","react-dom":"19.3.0"},"devDependencies":{"@types/react":"19.3.0","@types/react-dom":"19.3.0","typescript":"7.0.2"}}

What does this configuration do?

  • The scripts section gives npm commands for development builds.
  • The next dependency pins Next.js to 16.3.8.
  • The react dependency pins React to 19.3.0.
  • The typescript development dependency pins the compiler to 7.0.2.
  • Press Cmd+S to save package.json.
  • Confirm package.json appears directly inside aem-content-hub in the Explorer sidebar.

Does the package file show a problem?

  • Confirm the file is named package.json with no extra file extension.
  • Compare every brace with the reference code.

Still stuck? Help me check my package.json configuration..

  • Select New File... in the Explorer toolbar.
  • Enter tsconfig.json.
  • Press Return.
  • Add the TypeScript configuration by pasting this code into tsconfig.json:
{"compilerOptions":{"target":"ES2017","lib":["dom","dom.iterable","esnext"],"allowJs":true,"skipLibCheck":true,"strict":true,"noEmit":true,"esModuleInterop":true,"module":"esnext","moduleResolution":"bundler","resolveJsonModule":true,"isolatedModules":true,"jsx":"react-jsx","incremental":true,"plugins":[{"name":"next"}],"paths":{"@/*":["./src/*"]}},"include":["next-env.d.ts","**/*.ts","**/*.tsx",".next/types/**/*.ts",".next/dev/types/**/*.ts"],"exclude":["node_modules"]}

What does the TypeScript configuration control?

  • The strict option enables stronger type checks.
  • The jsx option prepares JSX for the React runtime.
  • The paths mapping lets future files import from @/.
  • The next plugin connects TypeScript checks to Next.js.
  • Press Cmd+S to save tsconfig.json.
  • Confirm tsconfig.json appears beside package.json in the Explorer sidebar.

Does TypeScript flag the configuration?

  • Confirm the filename ends with .json.
  • Check that every option remains inside compilerOptions.

Need another pair of eyes? Help me inspect my tsconfig.json file..

Install the pinned dependencies

The project already has the required Node.js runtime at version 20.9+. A successful dependency installation also confirms that npm is available.

  • Select View in the VS Code menu bar.
  • Select Terminal.

The integrated terminal opens at the aem-content-hub workspace folder. It can stay busy between output lines while npm resolves the dependency tree.

  • Install the dependencies from package.json by running:
npm install

What does this command create?

The command installs every dependency listed in package.json. It records the resolved dependency tree in package-lock.json.

Future installations can use that lock file to reproduce the same package versions.

  • Wait for the terminal prompt to return.
  • Confirm package-lock.json appears inside aem-content-hub in the Explorer sidebar.

That dependency setup is complete. Your returned terminal prompt proves npm can install the project packages.

Did the dependency installation stop?

  • Confirm the VS Code Explorer heading shows aem-content-hub.
  • Check that package.json matches the configuration above.
  • Retry the installation after restoring your network connection.

Still blocked? Help me troubleshoot my npm installation..

Add and run the starter page

The src/app folder holds the application routes. Its root layout supplies the page shell while the page component supplies the visible home route.

  • Select the new-folder icon in the Explorer toolbar.
  • Enter src.
  • Press Return.
  • Select the new src folder.
  • Select the new-folder icon in the Explorer toolbar.
  • Enter app.
  • Press Return.

You should see an empty app folder nested inside src.

  • Select the app folder in Explorer.
  • Select New File... in the Explorer toolbar.
  • Enter globals.css.
  • Press Return.
  • Add the starter page styles by pasting this code into src/app/globals.css:
html { background: #07130f; }
body { margin: 0; background: #07130f; color: #f6f7ee; font-family: Arial, Helvetica, sans-serif; }
main { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: 64px 0 80px; }

What do these styles control?

  • The html rule gives the browser canvas a dark background.
  • The body rule removes its default margin.
  • The main rule constrains the page width.
  • Press Cmd+S to save src/app/globals.css.
  • Confirm globals.css appears inside src/app in Explorer.

Does the stylesheet show a problem?

  • Confirm each CSS rule has matching braces.
  • Check that the file is inside src/app.

Need help? Help me check my starter stylesheet..

  • Select the app folder in Explorer.
  • Select New File... in the Explorer toolbar.
  • Enter layout.tsx.
  • Press Return.
  • Add the root layout by pasting this code into src/app/layout.tsx:
import "./globals.css";

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return <html lang="en"><body>{children}</body></html>;
}

What does the root layout do?

  • The stylesheet import applies the shared page styles.
  • The RootLayout component wraps every route in the document shell.
  • The children value holds the active route content.
  • Press Cmd+S to save src/app/layout.tsx.
  • Confirm layout.tsx appears beside globals.css in Explorer.

Does the layout show a type problem?

  • Confirm the filename is layout.tsx.
  • Check that the stylesheet import uses ./globals.css.

Still seeing a problem? Help me inspect my root layout..

  • Select the app folder in Explorer.
  • Select New File... in the Explorer toolbar.
  • Enter page.tsx.
  • Press Return.
  • Add the home route by pasting this code into src/app/page.tsx:
export default function Home() {
  return <main><h1>AEM Content Hub is ready.</h1></main>;
}

What does the home page render?

  • The Home component supplies the content for the root route.
  • The main element receives the shared width rules from the stylesheet.
  • The heading gives the development server a clear success signal.
  • Press Cmd+S to save src/app/page.tsx.
  • Confirm page.tsx appears beside the other files inside src/app.

Does the page show a syntax problem?

  • Confirm the file is named page.tsx.
  • Check that the component contains matching parentheses.

Need a closer check? Help me inspect my starter page component..

✔️ Awesome, I've got everything!

Your five project files are in place. Saved editor tabs have no unsaved-dot indicator.

ⓧ I'd like to double check the full code

These reference files show the exact workspace state before the development server starts.

{"name":"aem-content-hub","version":"0.1.0","private":true,"scripts":{"dev":"next dev","build":"next build","start":"next start"},"dependencies":{"next":"16.3.8","react":"19.3.0","react-dom":"19.3.0"},"devDependencies":{"@types/react":"19.3.0","@types/react-dom":"19.3.0","typescript":"7.0.2"}}
{"compilerOptions":{"target":"ES2017","lib":["dom","dom.iterable","esnext"],"allowJs":true,"skipLibCheck":true,"strict":true,"noEmit":true,"esModuleInterop":true,"module":"esnext","moduleResolution":"bundler","resolveJsonModule":true,"isolatedModules":true,"jsx":"react-jsx","incremental":true,"plugins":[{"name":"next"}],"paths":{"@/*":["./src/*"]}},"include":["next-env.d.ts","**/*.ts","**/*.tsx",".next/types/**/*.ts",".next/dev/types/**/*.ts"],"exclude":["node_modules"]}
html { background: #07130f; }
body { margin: 0; background: #07130f; color: #f6f7ee; font-family: Arial, Helvetica, sans-serif; }
main { width: min(1120px, calc(100% - 40px)); margin: 0 auto; padding: 64px 0 80px; }
import "./globals.css";

export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  return <html lang="en"><body>{children}</body></html>;
}
export default function Home() {
  return <main><h1>AEM Content Hub is ready.</h1></main>;
}

The development server occupies this terminal while it runs. Control+C stops it when you finish working later.

Before you start the server, what do you expect the browser to show from the Home component?

  • Start the local development server by running:
npm run dev

What does the development command do?

The command runs the dev script from package.json. Next.js compiles the application for local development.

The running server makes the root route available at http://localhost:3000.

  • Select http://localhost:3000 in the terminal output.

You should see AEM Content Hub is ready. on a dark page. Your local Next.js application is now taking browser requests.

Can't see the starter message?

  • Confirm the development server is still running in the VS Code terminal.
  • Check that your browser is using http://localhost:3000.
  • Compare the five project files with the full-code reference above.

Still blocked? Help me troubleshoot my local Next.js page..

Your workspace is running with every dependency pinned. Next, you will turn this starter message into an adventure catalog driven by an AEM-shaped response.

Render the Adventure Catalog

Your local Next.js starter now gives you a fast path from a saved file to a browser result. The next milestone is a screen that feels like a real content experience.

An AEM-shaped response lets local structured content drive a React interface. This proves the content contract can support the catalog before any remote service enters the project.

In this step, get ready to:
  • Define typed contracts for an adventure list response.
  • Create three local Content Fragment records.
  • Render a responsive catalog and test its card links.
Define the response contracts

A TypeScript contract describes the fields the page can use safely. The nested response contract mirrors the GraphQL path that contains the adventure records.

  • Create src/lib/types.ts from the file sidebar in VS Code.
  • Add the adventure fields and response envelope by pasting this code into src/lib/types.ts:
export type Adventure = {
  _path: string;
  slug: string;
  title: string;
  activity: string;
  price: number;
  tripLength: string;
  summary: string;
  highlights: string[];
};

export type AemAdventureListResponse = {
  data: {
    adventureList: {
      items: Adventure[];
    };
  };
};

What Do These Contracts Describe?

  • The Adventure type defines one complete adventure record.
  • The AemAdventureListResponse type places the records inside data.adventureList.items.
  • The nested envelope gives the local data the same list shape that the page expects later.
  • Save src/lib/types.ts.
  • Confirm the file sidebar shows types.ts inside src/lib.

Seeing a TypeScript Error?

Check that every property has a colon. Confirm that each nested brace closes at the correct level.

Still stuck? Help me check the contracts in types.ts.

✔️ Awesome, I've got everything!

Your contracts now describe one adventure and the complete list-response envelope.

ⓧ I'd like to double check the full code

export type Adventure = {
  _path: string;
  slug: string;
  title: string;
  activity: string;
  price: number;
  tripLength: string;
  summary: string;
  highlights: string[];
};

export type AemAdventureListResponse = {
  data: {
    adventureList: {
      items: Adventure[];
    };
  };
};

Compare every property name and nested brace with your file. The items property must contain an array of Adventure records.

Create the local response

The contracts establish the shape of the data. A typed local response now supplies the content that fills that shape.

  • Create src/lib/mock-data.ts from the file sidebar.
  • Add the response envelope and the Patagonia record by pasting this code into src/lib/mock-data.ts:
import type { AemAdventureListResponse } from "@/lib/types";

export const mockAemResponse: AemAdventureListResponse = {
  data: {
    adventureList: {
      items: [
        {
          _path: "/content/dam/nextwork-adventures/patagonia-ridge-trek",
          slug: "patagonia-ridge-trek",
          title: "Patagonia Ridge Trek",
          activity: "Hiking",
          price: 1890,
          tripLength: "7 days",
          summary:
            "Cross wind-carved ridges, glacial valleys, and remote camps with a small trekking group.",
          highlights: [
            "Guided ridge crossing",
            "Glacier viewpoints",
            "Three wilderness camps",
          ],
        },
      ],
    },
  },
};

What Does This Response Contain?

  • The type-only import connects the fixture to the response contract.
  • The mockAemResponse object follows the data.adventureList.items envelope.
  • The first item combines a fragment path with the content needed for Patagonia Ridge Trek.
  • Save src/lib/mock-data.ts.
  • Confirm the editor shows no red error markers in the new file.

Is the Type Import Unresolved?

Confirm that types.ts and mock-data.ts are both inside src/lib.

Need another pair of eyes? Help me resolve the type import in mock-data.ts.

Every item must satisfy the same field contract. The second record keeps that structure while supplying a different route slug and trip description.

  • Insert the Madeira object after the Patagonia object inside the items array by pasting this code:
        {
          _path: "/content/dam/nextwork-adventures/madeira-trail-circuit",
          slug: "madeira-trail-circuit",
          title: "Madeira Trail Circuit",
          activity: "Trail running",
          price: 1240,
          tripLength: "5 days",
          summary:
            "Follow levadas and volcanic ridgelines through Madeira's highland trail network.",
          highlights: [
            "Levada warm-up route",
            "Pico ridge traverse",
            "Coastal recovery day",
          ],
        },

How Does the Second Record Fit?

The Madeira object uses the same eight fields as the Patagonia object. A consistent structure lets one page render both records through the same card markup.

  • Save src/lib/mock-data.ts.
  • Confirm Madeira Trail Circuit appears directly after the Patagonia record.

Is Madeira Outside the Array?

Move the Madeira object above the closing bracket for items. Keep the comma after the Patagonia object.

Still seeing a nesting error? Help me place the Madeira record correctly.

The final record completes the response that drives the catalog. Its slug gives the third card a unique destination.

  • Insert the Norway object after the Madeira object inside the items array by pasting this code:
        {
          _path: "/content/dam/nextwork-adventures/norway-fjord-kayak",
          slug: "norway-fjord-kayak",
          title: "Norway Fjord Kayak",
          activity: "Kayaking",
          price: 1560,
          tripLength: "6 days",
          summary:
            "Paddle calm fjord water beneath steep cliffs and camp beside quiet shoreline villages.",
          highlights: [
            "Sea-kayak coaching",
            "Waterfall landing",
            "Village-to-village route",
          ],
        },

What Completes the Fixture?

The Norway object brings the typed response to three records. Each record now carries its own fragment path and route slug.

  • Save src/lib/mock-data.ts.
  • Search src/lib/mock-data.ts for _path.

You should find three fragment paths. That count confirms the response contains one complete object for each adventure.

Do You Find Fewer Than Three Paths?

Check that each adventure object includes one _path property. Confirm that the newest object did not replace an earlier record.

Still missing an item? Help me compare the three mock records.

✔️ Awesome, I've got everything!

Your local response now contains three typed adventures inside the expected envelope.

ⓧ I'd like to double check the full code

import type { AemAdventureListResponse } from "@/lib/types";

export const mockAemResponse: AemAdventureListResponse = {
  data: {
    adventureList: {
      items: [
        {
          _path: "/content/dam/nextwork-adventures/patagonia-ridge-trek",
          slug: "patagonia-ridge-trek",
          title: "Patagonia Ridge Trek",
          activity: "Hiking",
          price: 1890,
          tripLength: "7 days",
          summary:
            "Cross wind-carved ridges, glacial valleys, and remote camps with a small trekking group.",
          highlights: [
            "Guided ridge crossing",
            "Glacier viewpoints",
            "Three wilderness camps",
          ],
        },
        {
          _path: "/content/dam/nextwork-adventures/madeira-trail-circuit",
          slug: "madeira-trail-circuit",
          title: "Madeira Trail Circuit",
          activity: "Trail running",
          price: 1240,
          tripLength: "5 days",
          summary:
            "Follow levadas and volcanic ridgelines through Madeira's highland trail network.",
          highlights: [
            "Levada warm-up route",
            "Pico ridge traverse",
            "Coastal recovery day",
          ],
        },
        {
          _path: "/content/dam/nextwork-adventures/norway-fjord-kayak",
          slug: "norway-fjord-kayak",
          title: "Norway Fjord Kayak",
          activity: "Kayaking",
          price: 1560,
          tripLength: "6 days",
          summary:
            "Paddle calm fjord water beneath steep cliffs and camp beside quiet shoreline villages.",
          highlights: [
            "Sea-kayak coaching",
            "Waterfall landing",
            "Village-to-village route",
          ],
        },
      ],
    },
  },
};

Compare the response nesting and record order with your file. Every object must include all eight fields from the Adventure contract.

Build and style the catalog

The page can now read a predictable array from mockAemResponse. A mapped card structure turns each array item into visible catalog content.

  • Switch back to src/app/page.tsx.
  • Replace the starter component with this page foundation:
import Link from "next/link";
import { mockAemResponse } from "@/lib/mock-data";

export default function Home() {
  const adventures = mockAemResponse.data.adventureList.items;

  return (
    <main>
      <section className="hero">
        <div>
          <p className="eyebrow">NextWork Adventures</p>
          <h1>Structured stories for ambitious weekends.</h1>
          <p className="heroCopy">
            A Next.js content experience shaped like an AEM Headless delivery
            application.
          </p>
        </div>
      </section>
    </main>
  );
}

What Does the Page Foundation Do?

  • The mockAemResponse import gives the page access to the local response.
  • The adventures constant reads the array from data.adventureList.items.
  • The hero introduces the content experience that the cards support.
  • Save src/app/page.tsx.
  • Return to http://localhost:3000 in your browser.
  • Refresh the page.

You should see the NextWork Adventures label above a new headline. The starter message has been replaced.

Still Seeing the Starter Message?

Confirm that you edited src/app/page.tsx inside aem-content-hub. Save the file before refreshing.

If the development server has stopped, return to the terminal from the previous step. Help me find why page.tsx is not updating.

The catalog section reads the record count from the array. Its mapping expression creates one linked article for every adventure.

  • Insert this catalog section below the closing hero section in src/app/page.tsx:
      <section className="catalog" aria-labelledby="catalog-heading">
        <div className="sectionHeading">
          <div>
            <p className="eyebrow">Adventure catalog</p>
            <h2 id="catalog-heading">Choose your next route</h2>
          </div>
          <span>{adventures.length} Content Fragments</span>
        </div>

        <div className="grid">
          {adventures.map((adventure, index) => (
            <article className="card" key={adventure._path}>
              <div className={`cardArt cardArt${index + 1}`}>
                <span>{adventure.activity}</span>
              </div>
              <div className="cardBody">
                <div className="cardMeta">
                  <span>{adventure.tripLength}</span>
                  <span>${adventure.price.toLocaleString("en-US")}</span>
                </div>
                <h3>{adventure.title}</h3>
                <p>{adventure.summary}</p>
                <Link href={`/adventures/${adventure.slug}`}>
                  View adventure
                </Link>
              </div>
            </article>
          ))}
        </div>
      </section>

How Does the Mapping Work?

  • The map() callback runs once for every adventure.
  • The fragment path supplies a stable key for each card.
  • The array index selects a different artwork class for each item.
  • The slug creates a unique destination under /adventures/.
  • Save src/app/page.tsx.
  • Refresh http://localhost:3000.

You should see three plain card articles below the catalog heading. The fragment count should show three items.

Do the Cards Fail to Render?

Confirm that the catalog section sits inside the main element. Check that the mapping expression closes after the article element.

Still seeing a rendering error? Help me fix the catalog mapping.

✔️ Awesome, I've got everything!

Your page now reads the local response and renders one linked article for each adventure.

ⓧ I'd like to double check the full code

import Link from "next/link";
import { mockAemResponse } from "@/lib/mock-data";

export default function Home() {
  const adventures = mockAemResponse.data.adventureList.items;

  return (
    <main>
      <section className="hero">
        <div>
          <p className="eyebrow">NextWork Adventures</p>
          <h1>Structured stories for ambitious weekends.</h1>
          <p className="heroCopy">
            A Next.js content experience shaped like an AEM Headless delivery
            application.
          </p>
        </div>
      </section>

      <section className="catalog" aria-labelledby="catalog-heading">
        <div className="sectionHeading">
          <div>
            <p className="eyebrow">Adventure catalog</p>
            <h2 id="catalog-heading">Choose your next route</h2>
          </div>
          <span>{adventures.length} Content Fragments</span>
        </div>

        <div className="grid">
          {adventures.map((adventure, index) => (
            <article className="card" key={adventure._path}>
              <div className={`cardArt cardArt${index + 1}`}>
                <span>{adventure.activity}</span>
              </div>
              <div className="cardBody">
                <div className="cardMeta">
                  <span>{adventure.tripLength}</span>
                  <span>${adventure.price.toLocaleString("en-US")}</span>
                </div>
                <h3>{adventure.title}</h3>
                <p>{adventure.summary}</p>
                <Link href={`/adventures/${adventure.slug}`}>
                  View adventure
                </Link>
              </div>
            </article>
          ))}
        </div>
      </section>
    </main>
  );
}

Compare the imports and response lookup with your file. Confirm that every link uses the current record's slug value.

The page structure is complete. The CSS layer now turns those articles into a responsive card grid.

  • Switch back to src/app/globals.css.
  • Replace the starter styles with these color and page-foundation rules:
:root {
  color-scheme: dark;
  --background: #07130f;
  --surface: #10221b;
  --surface-soft: #183127;
  --line: rgba(255, 255, 255, 0.12);
  --text: #f6f7ee;
  --muted: #b8c5bd;
  --accent: #c8ff73;
}

* {
  box-sizing: border-box;
}

html {
  background: var(--background);
}

body {
  margin: 0;
  color: var(--text);
  background:
    radial-gradient(circle at 10% 0%, rgba(66, 125, 95, 0.32), transparent 34rem),
    var(--background);
  font-family: Arial, Helvetica, sans-serif;
}

What Does the Foundation Control?

  • The custom properties keep the surface and text colors consistent.
  • The universal box model keeps padding inside each element's calculated size.
  • The body adds the dark background with a soft green highlight.
  • Save src/app/globals.css.
  • Refresh the catalog.

You should see a soft green glow replace the flat background near the top-left corner.

Is the Background Still Flat?

Confirm that src/app/layout.tsx still imports ./globals.css. Check that the body rule includes both background layers.

Need help locating the missing style? Help me troubleshoot the global background.

  • Append the centered page layout and hero styles to src/app/globals.css by pasting this code:
a {
  color: inherit;
}

main {
  width: min(1120px, calc(100% - 40px));
  margin: 0 auto;
  padding: 64px 0 80px;
}

.hero {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.6fr);
  gap: 48px;
  align-items: end;
  min-height: 360px;
  padding-bottom: 56px;
}

.eyebrow {
  margin: 0 0 14px;
  color: var(--accent);
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

How Does the Hero Get Its Shape?

The main container keeps the page centered within a maximum width. The hero reserves space for the opening statement.

The eyebrow rule turns short labels into compact accent text.

  • Save src/app/globals.css.
  • Refresh the catalog.

You should see the content centered in a wide column. The NextWork Adventures label should appear in green uppercase text.

Is the Hero Still Cramped?

Check that the page container selector is main. Confirm that the hero selector starts with a period.

Still missing the spacing? Help me troubleshoot the hero layout.

  • Append the heading styles below the .eyebrow rule by pasting this code:
h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  max-width: 760px;
  margin-bottom: 22px;
  font-size: clamp(3rem, 8vw, 6.4rem);
  line-height: 0.92;
  letter-spacing: -0.055em;
}

h2 {
  margin-bottom: 0;
  font-size: clamp(2rem, 4vw, 3.3rem);
  letter-spacing: -0.035em;
}

h3 {
  margin-bottom: 12px;
  font-size: 1.65rem;
  letter-spacing: -0.025em;
}

Why Use Responsive Heading Sizes?

The clamp() values let the headings scale with the viewport. Their minimum and maximum values preserve readable limits.

  • Save src/app/globals.css.
  • Refresh the catalog.

You should see a large hero headline with compact line spacing. The catalog title should have a separate visual level.

Are the Headings Still the Same Size?

Keep the shared margin selector separate from the individual heading rules. Check every closing brace after the h1 and h2 blocks.

Need help checking the selectors? Help me fix the heading styles.

  • Append the supporting-copy and integration-panel styles by pasting this code:
.heroCopy,
.detailSummary {
  max-width: 670px;
  color: var(--muted);
  font-size: 1.12rem;
  line-height: 1.75;
}

.integrationCard {
  display: grid;
  gap: 10px;
  padding: 22px;
  border: 1px solid var(--line);
  border-radius: 18px;
  background: rgba(16, 34, 27, 0.78);
}

.integrationCard span,
.stats span {
  color: var(--muted);
  font-size: 0.78rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

What Changes in the Supporting Copy?

The hero description uses the muted color and a wider line height. The integration selectors prepare a matching surface for source details added later.

  • Save src/app/globals.css.
  • Refresh the catalog.

You should see the hero description become muted beneath the headline.

Is the Hero Description Still Bright?

Confirm that the paragraph uses className="heroCopy" in src/app/page.tsx.

Still not matching? Help me connect heroCopy to its CSS rule.

  • Append the integration-code and catalog-heading styles by pasting this code:
.integrationCard code,
.stats code {
  overflow-wrap: anywhere;
  color: var(--accent);
  font-size: 0.78rem;
}

.catalog {
  padding-top: 44px;
  border-top: 1px solid var(--line);
}

.sectionHeading {
  display: flex;
  gap: 24px;
  align-items: end;
  justify-content: space-between;
  margin-bottom: 30px;
}

.sectionHeading > span {
  color: var(--muted);
}

How Does the Catalog Separate Itself?

A subtle border marks the transition from the hero to the catalog. The section heading places the fragment count opposite the title on wide screens.

  • Save src/app/globals.css.
  • Refresh the catalog.

You should see a divider above the catalog. The fragment count should sit opposite the catalog heading.

Is the Fragment Count Misaligned?

Confirm that .sectionHeading uses a flex layout. Check that the count remains a direct child of that element.

Need help with the alignment? Help me fix the catalog heading layout.

  • Append the grid and artwork styles by pasting this code:
.grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 22px;
}

.card {
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 24px;
  background: var(--surface);
}

.cardArt {
  min-height: 180px;
  padding: 20px;
  display: flex;
  align-items: flex-start;
  background: linear-gradient(135deg, #335d48, #89b871);
}

.cardArt2 {
  background: linear-gradient(135deg, #604335, #d89d58);
}

.cardArt3 {
  background: linear-gradient(135deg, #244c60, #61a8b3);
}

How Do the Cards Become a Grid?

The grid assigns one equal column to each adventure on wide screens. The index-based classes give every artwork panel a distinct gradient.

  • Save src/app/globals.css.
  • Refresh the catalog.

You should see three equal-width cards in one row. Each card should have a different gradient panel.

Are the Cards Still Stacked?

Confirm that each card sits inside the element with className="grid". Check that the grid rule repeats three columns.

Still seeing one column? Help me troubleshoot the card grid.

  • Append the card-label and card-body styles by pasting this code:
.cardArt span,
.pill {
  padding: 7px 11px;
  border-radius: 999px;
  color: #07130f;
  background: var(--accent);
  font-size: 0.75rem;
  font-weight: 800;
  text-transform: uppercase;
}

.cardBody {
  padding: 24px;
}

.cardBody p {
  min-height: 72px;
  color: var(--muted);
  line-height: 1.55;
}

.cardBody a,
.backLink,
.notFound a {
  color: var(--accent);
  font-weight: 800;
  text-decoration-thickness: 2px;
  text-underline-offset: 5px;
}

What Makes the Cards Scannable?

The activity becomes a compact accent label over the artwork. A consistent summary height keeps the card links aligned.

The accent link style gives each navigation action clear visual priority.

  • Save src/app/globals.css.
  • Refresh the catalog.

You should see green activity labels above padded card bodies. The summaries should use muted text.

Are the Activity Labels Plain?

Confirm that each activity sits inside a span within .cardArt.

Need help matching the selector? Help me fix the activity labels.

  • Append the metadata and detail-shell styles by pasting this code:
.cardMeta {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;
  color: var(--muted);
  font-size: 0.85rem;
}

.detailShell {
  max-width: 900px;
}

.backLink {
  display: inline-block;
  margin-bottom: 36px;
}

.detailCard {
  padding: clamp(28px, 6vw, 64px);
  border: 1px solid var(--line);
  border-radius: 28px;
  background: var(--surface);
}

What Does the Metadata Row Show?

The metadata row places the trip length opposite the formatted price. Its muted styling separates those facts from the title.

The detail selectors establish surfaces that the adventure routes can reuse.

  • Save src/app/globals.css.
  • Refresh the catalog.

You should see each duration aligned opposite its price above the card title.

Are Duration and Price Stacked?

Confirm that both values are direct children of the element with className="cardMeta".

Still misaligned? Help me fix the metadata row.

  • Append the detail-stat and highlight-container styles by pasting this code:
.detailCard h1,
.notFound h1 {
  font-size: clamp(2.8rem, 7vw, 5.3rem);
}

.stats {
  display: grid;
  grid-template-columns: 1fr 1fr 2fr;
  gap: 20px;
  margin: 40px 0;
}

.stats div {
  display: grid;
  gap: 8px;
  padding: 18px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface-soft);
}

.highlights {
  padding-top: 30px;
  border-top: 1px solid var(--line);
}

What Do These Shared Styles Prepare?

These selectors define the heading scale and statistics grid for the upcoming detail screen. The highlight container gives the route content a clear section boundary.

  • Save src/app/globals.css.
  • Confirm the editor shows no CSS error markers around .stats.

Does the Editor Flag the Stats Rules?

Check the closing brace after .stats div. Confirm that the column declaration contains three tracks.

Need help locating the syntax issue? Help me check the detail statistics CSS.

  • Append the highlight-list and not-found styles by pasting this code:
.highlights h2 {
  font-size: 1.6rem;
}

.highlights li {
  margin: 12px 0;
  color: var(--muted);
}

.notFound {
  max-width: 760px;
  min-height: 100vh;
  display: grid;
  align-content: center;
}

.notFound p:not(.eyebrow) {
  color: var(--muted);
  font-size: 1.1rem;
}

How Are Secondary States Kept Consistent?

The highlight list uses the same muted supporting color as the catalog. The not-found container prepares a centered message within the shared page width.

  • Save src/app/globals.css.
  • Confirm the editor recognizes the .notFound selectors without an error marker.

Is the Not-Found Selector Invalid?

Keep p:not(.eyebrow) attached to .notFound with one space between the selectors.

Still seeing a warning? Help me check the not-found selector.

The desktop grid fits three cards across the page. A media query lets that layout adapt when the available width becomes smaller.

  • Append the responsive media query at the bottom of src/app/globals.css by pasting this code:
@media (max-width: 800px) {
  .hero,
  .grid,
  .stats {
    grid-template-columns: 1fr;
  }

  .hero {
    min-height: auto;
  }

  .sectionHeading {
    align-items: flex-start;
    flex-direction: column;
  }
}

What Happens on a Narrow Screen?

The hero and card grid collapse to one column below the breakpoint. The catalog count also moves beneath the section title.

  • Save src/app/globals.css.
  • Narrow the browser window below the desktop card layout.

You should see the three cards stack into one column. The fragment count should move beneath the catalog heading.

Do the Cards Stay in Three Columns?

Confirm that the media query sits at the bottom of the stylesheet. Check that .grid appears in the grouped selector.

Need help with the responsive rule? Help me fix the catalog media query.

✔️ Awesome, I've got everything!

Your catalog now uses the complete responsive stylesheet.

ⓧ I'd like to double check the full code

:root {
  color-scheme: dark;
  --background: #07130f;
  --surface: #10221b;
  --surface-soft: #183127;
  --line: rgba(255, 255, 255, 0.12);
  --text: #f6f7ee;
  --muted: #b8c5bd;
  --accent: #c8ff73;
}

* {
  box-sizing: border-box;
}

html {
  background: var(--background);
}

body {
  margin: 0;
  color: var(--text);
  background:
    radial-gradient(circle at 10% 0%, rgba(66, 125, 95, 0.32), transparent 34rem),
    var(--background);
  font-family: Arial, Helvetica, sans-serif;
}

a {
  color: inherit;
}

main {
  width: min(1120px, calc(100% - 40px));
  margin: 0 auto;
  padding: 64px 0 80px;
}

.hero {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.6fr);
  gap: 48px;
  align-items: end;
  min-height: 360px;
  padding-bottom: 56px;
}

.eyebrow {
  margin: 0 0 14px;
  color: var(--accent);
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

h1,
h2,
h3,
p {
  margin-top: 0;
}

h1 {
  max-width: 760px;
  margin-bottom: 22px;
  font-size: clamp(3rem, 8vw, 6.4rem);
  line-height: 0.92;
  letter-spacing: -0.055em;
}

h2 {
  margin-bottom: 0;
  font-size: clamp(2rem, 4vw, 3.3rem);
  letter-spacing: -0.035em;
}

h3 {
  margin-bottom: 12px;
  font-size: 1.65rem;
  letter-spacing: -0.025em;
}

.heroCopy,
.detailSummary {
  max-width: 670px;
  color: var(--muted);
  font-size: 1.12rem;
  line-height: 1.75;
}

.integrationCard {
  display: grid;
  gap: 10px;
  padding: 22px;
  border: 1px solid var(--line);
  border-radius: 18px;
  background: rgba(16, 34, 27, 0.78);
}

.integrationCard span,
.stats span {
  color: var(--muted);
  font-size: 0.78rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.integrationCard code,
.stats code {
  overflow-wrap: anywhere;
  color: var(--accent);
  font-size: 0.78rem;
}

.catalog {
  padding-top: 44px;
  border-top: 1px solid var(--line);
}

.sectionHeading {
  display: flex;
  gap: 24px;
  align-items: end;
  justify-content: space-between;
  margin-bottom: 30px;
}

.sectionHeading > span {
  color: var(--muted);
}

.grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 22px;
}

.card {
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 24px;
  background: var(--surface);
}

.cardArt {
  min-height: 180px;
  padding: 20px;
  display: flex;
  align-items: flex-start;
  background: linear-gradient(135deg, #335d48, #89b871);
}

.cardArt2 {
  background: linear-gradient(135deg, #604335, #d89d58);
}

.cardArt3 {
  background: linear-gradient(135deg, #244c60, #61a8b3);
}

.cardArt span,
.pill {
  padding: 7px 11px;
  border-radius: 999px;
  color: #07130f;
  background: var(--accent);
  font-size: 0.75rem;
  font-weight: 800;
  text-transform: uppercase;
}

.cardBody {
  padding: 24px;
}

.cardBody p {
  min-height: 72px;
  color: var(--muted);
  line-height: 1.55;
}

.cardBody a,
.backLink,
.notFound a {
  color: var(--accent);
  font-weight: 800;
  text-decoration-thickness: 2px;
  text-underline-offset: 5px;
}

.cardMeta {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;
  color: var(--muted);
  font-size: 0.85rem;
}

.detailShell {
  max-width: 900px;
}

.backLink {
  display: inline-block;
  margin-bottom: 36px;
}

.detailCard {
  padding: clamp(28px, 6vw, 64px);
  border: 1px solid var(--line);
  border-radius: 28px;
  background: var(--surface);
}

.detailCard h1,
.notFound h1 {
  font-size: clamp(2.8rem, 7vw, 5.3rem);
}

.stats {
  display: grid;
  grid-template-columns: 1fr 1fr 2fr;
  gap: 20px;
  margin: 40px 0;
}

.stats div {
  display: grid;
  gap: 8px;
  padding: 18px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--surface-soft);
}

.highlights {
  padding-top: 30px;
  border-top: 1px solid var(--line);
}

.highlights h2 {
  font-size: 1.6rem;
}

.highlights li {
  margin: 12px 0;
  color: var(--muted);
}

.notFound {
  max-width: 760px;
  min-height: 100vh;
  display: grid;
  align-content: center;
}

.notFound p:not(.eyebrow) {
  color: var(--muted);
  font-size: 1.1rem;
}

@media (max-width: 800px) {
  .hero,
  .grid,
  .stats {
    grid-template-columns: 1fr;
  }

  .hero {
    min-height: auto;
  }

  .sectionHeading {
    align-items: flex-start;
    flex-direction: column;
  }
}

Compare the selector order and final media query with your file. Matching the complete stylesheet prevents an earlier missing brace from affecting later rules.

Before you refresh, how many styled cards do you expect the three-record response to produce?

  • Return the browser to a wide window.
  • Refresh http://localhost:3000.

You should see three styled cards for Patagonia Ridge Trek, Madeira Trail Circuit, and Norway Fjord Kayak. Each card should show its trip details and a View adventure link.

That is the first complete content-driven screen. Your typed response now controls the visible catalog.

Before you select a card, do you expect its generated URL to have a matching page in the app?

  • Select View adventure on any card.

You should reach the default 404 page. This intended shortfall proves the card generated a slug URL that the app cannot handle yet.

What Did the 404 Prove?

The link carries the selected record's slug into a distinct URL. The missing destination exposes the gap in the browsing journey.

  • Use the browser's Back control to return to the catalog.

Did the Link Fail Differently?

Confirm that the destination begins with /adventures/ and ends with the selected record's slug.

Need help tracing the route? Help me troubleshoot an adventure card link.

Your catalog is live with three routes waiting behind its cards. Next, you'll complete that browsing journey with dynamic adventure pages.

Add Dynamic Adventure Routes

Your Next.js catalog now turns an AEM-shaped response into three styled cards. Each card carries a route built from its adventure slug.

That journey currently stops at the default 404 because the app has no route that can read the slug. A dynamic route will connect each card to its matching Content Fragment.

In this step, get ready to:
  • Reproduce the broken card journey at a valid adventure slug.
  • Build a detail page that awaits the slug before finding its adventure.
  • Show a custom not-found page for unknown slugs.
Reproduce the broken card journey

A card already points to a specific adventure URL. The current result reveals whether a matching page exists.

Before you select a card, predict what the address bar will keep after Next.js tries to load the destination.

  • Switch back to the catalog in your browser.
  • Click View adventure on the Patagonia Ridge Trek card.

You'll see the default 404 page while the address bar keeps /adventures/patagonia-ridge-trek. The link has supplied a valid slug, but the application has no matching route yet.

This 404 Is Intentional

The catalog has completed its part of the journey by sending the slug. The missing dynamic page is the gap you are about to close.

  • Switch back to VS Code.
  • Create src/app/adventures inside src/app using the file sidebar.
  • Confirm adventures appears beneath src/app.
  • Create src/app/adventures/[slug] inside src/app/adventures using the file sidebar.
  • Confirm [slug] appears beneath adventures.
  • Create page.tsx inside src/app/adventures/[slug] using the file sidebar.

The square brackets make slug a dynamic segment. One page can now respond to every adventure slug.

  • Add the first runnable detail route to src/app/adventures/[slug]/page.tsx by pasting this code:
import Link from "next/link";
import { notFound } from "next/navigation";
import { mockAemResponse } from "@/lib/mock-data";

type AdventurePageProps = {
  params: Promise<{ slug: string }>;
};

export default async function AdventurePage({ params }: AdventurePageProps) {
  const { slug } = await params;
  const adventure = mockAemResponse.data.adventureList.items.find(
    (item) => item.slug === slug,
  );

  if (!adventure) {
    notFound();
  }

  return (
    <main className="detailShell">
      <article className="detailCard">
        <p className="eyebrow">{adventure.activity}</p>
        <h1>{adventure.title}</h1>
      </article>
    </main>
  );
}

How Does This Route Work?

  • The AdventurePageProps contract records that params arrives as a Promise containing the slug.
  • The page awaits params before reading slug.
  • The find() call searches the local fixture for a matching item.
  • The notFound() call stops rendering when the search produces no adventure.
  • Save src/app/adventures/[slug]/page.tsx.
  • Return to the browser tab showing the Patagonia URL.
  • Refresh the page.

You'll now see Patagonia Ridge Trek inside the styled detail card. That's the broken journey repaired at its first useful checkpoint.

Still Seeing the Default 404?

Check that page.tsx is nested inside src/app/adventures/[slug]. A file beside the dynamic folder cannot handle the slug.

Check the VS Code terminal for a path or syntax error. Keep the development server running while you correct the named file.

Still stuck? Help me diagnose why my Next.js dynamic adventure route still returns the default 404.

Build the complete adventure detail page

The route can identify an adventure now. The page needs navigation plus enough structured content to make each record useful.

  • In src/app/adventures/[slug]/page.tsx, find the current return block.
  • Replace that block with this expanded page structure:
  return (
    <main className="detailShell">
      <Link className="backLink" href="/">
        Back to all adventures
      </Link>

      <article className="detailCard">
        <p className="eyebrow">{adventure.activity}</p>
        <h1>{adventure.title}</h1>
        <p className="detailSummary">{adventure.summary}</p>
      </article>
    </main>
  );

What Does This Structure Add?

  • The back link gives every detail page a direct route to the catalog.
  • The activity appears as the eyebrow label above the adventure title.
  • The summary turns the selected Content Fragment into a useful introduction.
  • Save src/app/adventures/[slug]/page.tsx.
  • Refresh the Patagonia detail page.

You'll see the activity label above the title. The summary appears below it with a back link above the detail card.

Missing the Summary or Back Link?

Confirm the replacement starts at return ( and ends at the matching );. Leaving the old return block in place creates duplicate markup.

Need a second pair of eyes? Help me compare my adventure return block with the expected JSX.

The fixture also carries duration, price, and source-path data. A stats grid makes those values visible without changing the content contract.

  • In src/app/adventures/[slug]/page.tsx, find the detailSummary paragraph.
  • Insert this stats grid directly below that paragraph:
        <div className="stats">
          <div>
            <span>Duration</span>
            <strong>{adventure.tripLength}</strong>
          </div>
          <div>
            <span>From</span>
            <strong>${adventure.price.toLocaleString("en-US")}</strong>
          </div>
          <div>
            <span>Fragment path</span>
            <code>{adventure._path}</code>
          </div>
        </div>

What Does the Stats Grid Show?

  • The duration comes from tripLength in the matching record.
  • The price uses toLocaleString() to display a readable value.
  • The fragment path exposes the simulated source location carried by _path.
  • Save src/app/adventures/[slug]/page.tsx.
  • Refresh the Patagonia detail page.

You'll see three stat panels showing 7 days, $1,890, and the Patagonia fragment path.

Stats Grid Not Rendering?

Check that the entire stats block sits inside detailCard. Placing it after the article's closing tag changes the page structure.

Still seeing a compile error? Help me find the misplaced JSX tag in my adventure stats grid.

Each record finishes with three route highlights. Mapping the array keeps the page compatible with different highlight text for every adventure.

  • In src/app/adventures/[slug]/page.tsx, find the closing tag for the stats grid.
  • Insert this highlights section directly below the stats grid:
        <section className="highlights">
          <h2>Route highlights</h2>
          <ul>
            {adventure.highlights.map((highlight) => (
              <li key={highlight}>{highlight}</li>
            ))}
          </ul>
        </section>

How Are Highlights Rendered?

  • The map() call creates one list item for every highlight in the selected adventure.
  • Each highlight supplies its own key because the fixture contains unique text within that adventure.
  • The same page structure works for all three records because the content changes with the slug.
  • Save src/app/adventures/[slug]/page.tsx.
  • Refresh the Patagonia detail page.

You'll see a Route highlights section containing Guided ridge crossing, Glacier viewpoints, and Three wilderness camps.

Highlights Missing?

Confirm the highlights section sits inside detailCard after the stats grid. Check that adventure.highlights matches the contract exactly.

Need help locating the issue? Help me troubleshoot why my mapped adventure highlights do not appear.

✔️ Awesome, I've got everything!

Your dynamic detail route now matches the completed adventure page.

ⓧ I'd like to double check the full code

import Link from "next/link";
import { notFound } from "next/navigation";
import { mockAemResponse } from "@/lib/mock-data";

type AdventurePageProps = {
  params: Promise<{ slug: string }>;
};

export default async function AdventurePage({ params }: AdventurePageProps) {
  const { slug } = await params;
  const adventure = mockAemResponse.data.adventureList.items.find(
    (item) => item.slug === slug,
  );

  if (!adventure) {
    notFound();
  }

  return (
    <main className="detailShell">
      <Link className="backLink" href="/">
        Back to all adventures
      </Link>

      <article className="detailCard">
        <p className="eyebrow">{adventure.activity}</p>
        <h1>{adventure.title}</h1>
        <p className="detailSummary">{adventure.summary}</p>

        <div className="stats">
          <div>
            <span>Duration</span>
            <strong>{adventure.tripLength}</strong>
          </div>
          <div>
            <span>From</span>
            <strong>${adventure.price.toLocaleString("en-US")}</strong>
          </div>
          <div>
            <span>Fragment path</span>
            <code>{adventure._path}</code>
          </div>
        </div>

        <section className="highlights">
          <h2>Route highlights</h2>
          <ul>
            {adventure.highlights.map((highlight) => (
              <li key={highlight}>{highlight}</li>
            ))}
          </ul>
        </section>
      </article>
    </main>
  );
}
Handle unknown adventure slugs

The route calls notFound() when no record matches the slug. A custom not-found page turns that outcome into a clear recovery path.

  • Create src/app/not-found.tsx inside src/app using the file sidebar.
  • Confirm not-found.tsx appears beside page.tsx.
  • Add the custom not-found experience by pasting this code:
import Link from "next/link";

export default function NotFound() {
  return (
    <main className="notFound">
      <p className="eyebrow">Content Fragment not found</p>
      <h1>This adventure is not in the catalog.</h1>
      <p>The slug does not match any item returned by the content adapter.</p>
      <Link href="/">Return to the catalog</Link>
    </main>
  );
}

What Does This Page Do?

  • The page explains that the requested slug has no matching catalog item.
  • The link returns visitors to the catalog instead of leaving them at a dead end.
  • The existing notFound styles give the message a focused full-page layout.
  • Save src/app/not-found.tsx.

Before you test the finished flow, predict which page an unknown slug should reach. Hold that answer while you check all three paths.

  • Return to the browser.
  • Enter http://localhost:3000 in the address bar.
  • Press Enter.
  • Click View adventure on the Patagonia Ridge Trek card.

You'll see the complete Patagonia detail page with its summary, stats, fragment path, and route highlights.

  • Click Back to all adventures.

You'll return to the catalog with all three adventure cards still available.

  • Enter http://localhost:3000/adventures/not-real in the address bar.
  • Press Enter.

You'll see the custom Content Fragment not found message. The Return to the catalog link appears below it.

Custom Page Not Appearing?

Confirm the file is named exactly not-found.tsx inside src/app. Check that the detail route imports notFound from next/navigation.

If a valid slug reaches the custom page, compare the URL slug with the matching slug value in src/lib/mock-data.ts.

Still blocked? Help me troubleshoot my Next.js custom not-found flow for dynamic adventure routes.

✔️ Awesome, I've got everything!

Your custom not-found page now matches the completed project file.

ⓧ I'd like to double check the full code

import Link from "next/link";

export default function NotFound() {
  return (
    <main className="notFound">
      <p className="eyebrow">Content Fragment not found</p>
      <h1>This adventure is not in the catalog.</h1>
      <p>The slug does not match any item returned by the content adapter.</p>
      <Link href="/">Return to the catalog</Link>
    </main>
  );
}

That closes the catalog journey. Valid slugs now produce full detail pages while unknown slugs lead visitors safely back to the catalog.

Your catalog now supports browsing from card to detail page and back again. Next, you'll separate those pages from the local fixture with one content-access boundary.

Introduce the AEM Content Adapter

Your catalog links now lead to working detail pages. However, both pages still reach directly into the local fixture.

A server-side adapter boundary gives the UI one stable way to request content. It keeps the local response active today while containing the future Adobe Experience Manager (AEM) as a Cloud Service Publish request behind one file.

In this step, get ready to:
  • Create a typed adapter with mock content and guarded AEM Publish branches.
  • Refactor the catalog and detail route to request content through the adapter.
  • Build the project and verify the complete browsing journey.
Create the server-side content adapter

A persisted GraphQL query gives a client a stored query path that it can request with HTTP GET. The adapter owns that path plus source selection and response validation.

Why a server-side adapter?

The adapter gives both pages the same content API. A future source change stays inside src/lib/aem.ts.

Server-side fetching also keeps endpoint logic outside the browser bundle. That boundary prepares the app for an AEM Publish connection without changing its UI components.

  • Create aem.ts inside the existing src/lib folder from the VS Code Explorer sidebar.
  • Add the adapter configuration and persisted-query URL helper to src/lib/aem.ts by copying this code:
import { mockAemResponse } from "@/lib/mock-data";
import type { Adventure, AemAdventureListResponse } from "@/lib/types";

type ContentSource = "mock" | "aem";

type ContentConfig = {
  source: ContentSource;
  aemPublishHost: string;
  persistedQueryPath: string;
};

// Keep source selection and endpoint details in one server-side config.
function getContentConfig(): ContentConfig {
  return {
    source: "mock",
    aemPublishHost: "",
    persistedQueryPath: "wknd-shared/adventures-all",
  };
}

// Build the documented persisted-query GET path without a double slash.
function buildPersistedQueryUrl(host: string, path: string) {
  const normalizedHost = host.replace(/\/$/, "");
  return `${normalizedHost}/graphql/execute.json/${path}`;
}

What does this code do?

  • The ContentSource union limits source selection to the local mock or a future AEM connection.
  • The ContentConfig contract keeps the source, host, and persisted-query path together.
  • The empty aemPublishHost keeps remote access disabled.
  • The buildPersistedQueryUrl() helper removes one trailing slash before it joins the host to the persisted-query path.
  • Save src/lib/aem.ts.
  • Confirm the VS Code Explorer sidebar now lists aem.ts inside src/lib.

Does the new file show errors?

Check that aem.ts sits inside src/lib. The existing path alias resolves imports that begin with @/ from that location.

Compare the import names with mockAemResponse, Adventure, and AemAdventureListResponse in the existing library files.

Still stuck? Help me diagnose the TypeScript errors in my new adapter file.

The next function makes that configuration useful. Mock mode returns the current fixture immediately, while the dormant AEM branch protects itself with host, HTTP status, and response-shape checks.

  • Add the response loader below buildPersistedQueryUrl() in src/lib/aem.ts by copying this code:
// Load the local fixture now or a validated AEM response in the future.
async function loadAdventureResponse(): Promise<AemAdventureListResponse> {
  const config = getContentConfig();

  if (config.source === "mock") {
    return mockAemResponse;
  }

  if (!config.aemPublishHost) {
    throw new Error("Add an AEM Publish host before enabling the AEM source.");
  }

  const response = await fetch(
    buildPersistedQueryUrl(config.aemPublishHost, config.persistedQueryPath),
    { cache: "force-cache" },
  );

  if (!response.ok) {
    throw new Error(`AEM request failed with status ${response.status}.`);
  }

  const payload = (await response.json()) as Partial<AemAdventureListResponse>;

  if (!payload.data?.adventureList?.items) {
    throw new Error("AEM response did not contain data.adventureList.items.");
  }

  return payload as AemAdventureListResponse;
}

How is the future request guarded?

  • The mock branch returns mockAemResponse before any network request can run.
  • The host guard blocks AEM mode until aemPublishHost contains a value.
  • The status check rejects an unsuccessful HTTP response.
  • The shape check requires data.adventureList.items before the adapter accepts the payload.
  • Save src/lib/aem.ts.
  • Check the running development terminal. You should see the app compile without a TypeScript error.

Seeing an adapter error?

Check that the response loader appears below getContentConfig() and buildPersistedQueryUrl(). It calls both helpers.

Check every brace around the mock branch, host guard, status check, and shape check. One missing brace can make the later checks appear outside the function.

Need another pair of eyes? Help me find the syntax or type error in my response loader.

The loader handles source-specific work. Small exported functions now give the pages focused operations for listing content, finding one slug, and displaying integration details.

  • Add the public adapter functions below loadAdventureResponse() in src/lib/aem.ts by copying this code:
// Give pages a stable list API regardless of the active source.
export async function getAdventures(): Promise<Adventure[]> {
  const response = await loadAdventureResponse();
  return response.data.adventureList.items;
}

// Resolve one detail route from the shared adventure list.
export async function getAdventureBySlug(
  slug: string,
): Promise<Adventure | undefined> {
  const adventures = await getAdventures();
  return adventures.find((adventure) => adventure.slug === slug);
}

// Expose safe source details for the catalog integration panel.
export function getIntegrationDetails() {
  const config = getContentConfig();

  return {
    sourceLabel:
      config.source === "mock" ? "AEM-compatible mock" : "AEM Publish",
    requestPath: `/graphql/execute.json/${config.persistedQueryPath}`,
  };
}

What do the page-facing functions provide?

  • The getAdventures() function unwraps the AEM-style response envelope for the catalog.
  • The getAdventureBySlug() function reuses that list before finding one route record.
  • The getIntegrationDetails() function exposes safe display values without exposing the full configuration.
  • Save src/lib/aem.ts.
  • Refresh http://localhost:3000. You should still see the three adventure cards because the adapter remains in mock mode.

Did the catalog stop loading?

Check that source remains "mock" in getContentConfig(). This keeps the guarded network branch dormant.

Check that getAdventures() returns response.data.adventureList.items. That path matches the existing fixture envelope.

Still blocked? Help me trace why the adapter is not returning the mock adventure list.

✔️ Awesome, I've got everything!

Great. Double-check that you saved src/lib/aem.ts before refactoring the pages.

ⓧ I'd like to double check the full code

import { mockAemResponse } from "@/lib/mock-data";
import type { Adventure, AemAdventureListResponse } from "@/lib/types";

type ContentSource = "mock" | "aem";

type ContentConfig = {
  source: ContentSource;
  aemPublishHost: string;
  persistedQueryPath: string;
};

function getContentConfig(): ContentConfig {
  return {
    source: "mock",
    aemPublishHost: "",
    persistedQueryPath: "wknd-shared/adventures-all",
  };
}

function buildPersistedQueryUrl(host: string, path: string) {
  const normalizedHost = host.replace(/\/$/, "");
  return `${normalizedHost}/graphql/execute.json/${path}`;
}

async function loadAdventureResponse(): Promise<AemAdventureListResponse> {
  const config = getContentConfig();

  if (config.source === "mock") {
    return mockAemResponse;
  }

  if (!config.aemPublishHost) {
    throw new Error("Add an AEM Publish host before enabling the AEM source.");
  }

  const response = await fetch(
    buildPersistedQueryUrl(config.aemPublishHost, config.persistedQueryPath),
    { cache: "force-cache" },
  );

  if (!response.ok) {
    throw new Error(`AEM request failed with status ${response.status}.`);
  }

  const payload = (await response.json()) as Partial<AemAdventureListResponse>;

  if (!payload.data?.adventureList?.items) {
    throw new Error("AEM response did not contain data.adventureList.items.");
  }

  return payload as AemAdventureListResponse;
}

export async function getAdventures(): Promise<Adventure[]> {
  const response = await loadAdventureResponse();
  return response.data.adventureList.items;
}

export async function getAdventureBySlug(
  slug: string,
): Promise<Adventure | undefined> {
  const adventures = await getAdventures();
  return adventures.find((adventure) => adventure.slug === slug);
}

export function getIntegrationDetails() {
  const config = getContentConfig();

  return {
    sourceLabel:
      config.source === "mock" ? "AEM-compatible mock" : "AEM Publish",
    requestPath: `/graphql/execute.json/${config.persistedQueryPath}`,
  };
}
Refactor both pages around the adapter

The adapter only creates a boundary when every page uses it. The catalog now requests a list plus display-safe integration details, while the dynamic route requests one adventure by slug.

  • Replace the contents of src/app/page.tsx with this adapter-backed catalog:
import Link from "next/link";
import { getAdventures, getIntegrationDetails } from "@/lib/aem";

export default async function Home() {
  // Load page content and safe integration metadata on the server.
  const adventures = await getAdventures();
  const integration = getIntegrationDetails();

  return (
    <main>
      <section className="hero">
        <div>
          <p className="eyebrow">NextWork Adventures</p>
          <h1>Structured stories for ambitious weekends.</h1>
          <p className="heroCopy">
            A Next.js content experience shaped like an AEM Headless delivery
            application.
          </p>
        </div>

        <aside className="integrationCard">
          <span>Content source</span>
          <strong>{integration.sourceLabel}</strong>
          <code>{integration.requestPath}</code>
        </aside>
      </section>

What does this catalog section do?

  • The async Home Server Component awaits getAdventures() before rendering.
  • The integration panel shows the active source label and future request path returned by the adapter.
  • The hero stays focused on presentation because source configuration lives in src/lib/aem.ts.
  • Add the catalog section directly below the hero section in src/app/page.tsx by copying this code:
      <section className="catalog" aria-labelledby="catalog-heading">
        <div className="sectionHeading">
          <div>
            <p className="eyebrow">Adventure catalog</p>
            <h2 id="catalog-heading">Choose your next route</h2>
          </div>
          <span>{adventures.length} Content Fragments</span>
        </div>

        <div className="grid">
          {adventures.map((adventure, index) => (
            <article className="card" key={adventure._path}>
              <div className={`cardArt cardArt${index + 1}`}>
                <span>{adventure.activity}</span>
              </div>
              <div className="cardBody">
                <div className="cardMeta">
                  <span>{adventure.tripLength}</span>
                  <span>${adventure.price.toLocaleString("en-US")}</span>
                </div>
                <h3>{adventure.title}</h3>
                <p>{adventure.summary}</p>
                <Link href={`/adventures/${adventure.slug}`}>
                  View adventure
                </Link>
              </div>
            </article>
          ))}
        </div>
      </section>
    </main>
  );
}

How does the catalog stay source-independent?

  • The page maps the adventures array returned by the adapter.
  • Each Link still builds its dynamic route from adventure.slug.
  • The page no longer reads the nested fixture envelope or imports mockAemResponse.
  • Save src/app/page.tsx.
  • Refresh http://localhost:3000. You should see three cards plus an integration panel showing AEM-compatible mock.
  • Confirm the same panel shows /graphql/execute.json/wknd-shared/adventures-all.

Is the catalog missing or unstyled?

Check that the first line still imports Link from next/link. The card links depend on that import.

Check that the closing </section>, </main>, and function brace appear after the grid. A misplaced closing tag can hide the catalog.

Need help? Help me compare my adapter-backed catalog with the expected component structure.

✔️ Awesome, I've got everything!

The catalog now receives content through the adapter. Save src/app/page.tsx before updating the dynamic route.

ⓧ I'd like to double check the full code

import Link from "next/link";
import { getAdventures, getIntegrationDetails } from "@/lib/aem";

export default async function Home() {
  const adventures = await getAdventures();
  const integration = getIntegrationDetails();

  return (
    <main>
      <section className="hero">
        <div>
          <p className="eyebrow">NextWork Adventures</p>
          <h1>Structured stories for ambitious weekends.</h1>
          <p className="heroCopy">
            A Next.js content experience shaped like an AEM Headless delivery
            application.
          </p>
        </div>

        <aside className="integrationCard">
          <span>Content source</span>
          <strong>{integration.sourceLabel}</strong>
          <code>{integration.requestPath}</code>
        </aside>
      </section>

      <section className="catalog" aria-labelledby="catalog-heading">
        <div className="sectionHeading">
          <div>
            <p className="eyebrow">Adventure catalog</p>
            <h2 id="catalog-heading">Choose your next route</h2>
          </div>
          <span>{adventures.length} Content Fragments</span>
        </div>

        <div className="grid">
          {adventures.map((adventure, index) => (
            <article className="card" key={adventure._path}>
              <div className={`cardArt cardArt${index + 1}`}>
                <span>{adventure.activity}</span>
              </div>
              <div className="cardBody">
                <div className="cardMeta">
                  <span>{adventure.tripLength}</span>
                  <span>${adventure.price.toLocaleString("en-US")}</span>
                </div>
                <h3>{adventure.title}</h3>
                <p>{adventure.summary}</p>
                <Link href={`/adventures/${adventure.slug}`}>
                  View adventure
                </Link>
              </div>
            </article>
          ))}
        </div>
      </section>
    </main>
  );
}

The detail route has the same coupling problem as the catalog. One focused change replaces its fixture lookup with the adapter's slug function.

  • In src/app/adventures/[slug]/page.tsx, find the current fixture import:
import { mockAemResponse } from "@/lib/mock-data";
  • Replace that import with the adapter import shown here:
import { getAdventureBySlug } from "@/lib/aem";
  • In the same file, find the current fixture lookup:
  const adventure = mockAemResponse.data.adventureList.items.find((item) => item.slug === slug);
  • Replace that lookup with the awaited adapter call shown here:
  const adventure = await getAdventureBySlug(slug);
  • Save src/app/adventures/[slug]/page.tsx.
  • Select the Patagonia Ridge Trek card from the catalog. You should see its detail page with the title, summary, duration, price, fragment path, and route highlights.
  • Select Back to all adventures. You should return to the catalog with the integration panel still visible.

Does the detail route fail?

Check that getAdventureBySlug is imported from @/lib/aem. Remove the old mockAemResponse import.

Check that the call includes await. The adapter function returns a promise.

Still seeing a route error? Help me debug the adapter call in my dynamic adventure page.

✔️ Awesome, I've got everything!

Both pages now use the adapter. Double-check that src/app/adventures/[slug]/page.tsx is saved.

ⓧ I'd like to double check the full code

import Link from "next/link";
import { notFound } from "next/navigation";
import { getAdventureBySlug } from "@/lib/aem";

type AdventurePageProps = {
  params: Promise<{ slug: string }>;
};

export default async function AdventurePage({ params }: AdventurePageProps) {
  const { slug } = await params;
  const adventure = await getAdventureBySlug(slug);

  if (!adventure) {
    notFound();
  }

  return (
    <main className="detailShell">
      <Link className="backLink" href="/">
        Back to all adventures
      </Link>

      <article className="detailCard">
        <p className="eyebrow">{adventure.activity}</p>
        <h1>{adventure.title}</h1>
        <p className="detailSummary">{adventure.summary}</p>

        <div className="stats">
          <div>
            <span>Duration</span>
            <strong>{adventure.tripLength}</strong>
          </div>
          <div>
            <span>From</span>
            <strong>${adventure.price.toLocaleString("en-US")}</strong>
          </div>
          <div>
            <span>Fragment path</span>
            <code>{adventure._path}</code>
          </div>
        </div>

        <section className="highlights">
          <h2>Route highlights</h2>
          <ul>
            {adventure.highlights.map((highlight) => (
              <li key={highlight}>{highlight}</li>
            ))}
          </ul>
        </section>
      </article>
    </main>
  );
}
Build and inspect the complete journey

The development server proves the pages render during editing. A production build adds a stricter check across imports, routes, and TypeScript contracts.

  • Stop the running development server by pressing Control+C in the VS Code terminal.
  • Before you build, predict whether both routes now compile without importing the fixture directly.
  • Run the production build from the existing aem-content-hub workspace with this command:
npm run build

You should see the build complete successfully. This confirms that the adapter, catalog, and dynamic route agree on their TypeScript contracts.

Did the build fail?

Start with the first file path in the build output. Later messages often come from the same earlier syntax or import problem.

Check that both page imports point to @/lib/aem. Confirm that every called adapter function is exported from src/lib/aem.ts.

Want help reading the output? Help me diagnose my Next.js production build failure.

  • Restart the development server after the successful build by running:
npm run dev

You should see the development server start with the local app available at http://localhost:3000.

Does the server fail to restart?

Check that the earlier development process stopped before you restarted it. A process that still owns the local port can block a second server.

Still stuck? Help me restart the Next.js development server after a successful build.

Before the final check, predict which adapter details the catalog should reveal while remote access remains disabled.

  • Refresh http://localhost:3000 in your browser.
  • Confirm the integration panel shows AEM-compatible mock as the content source.
  • Confirm the integration panel shows /graphql/execute.json/wknd-shared/adventures-all as the request path.
  • Select the Patagonia Ridge Trek card.
  • Confirm the detail page shows the Patagonia title, trip data, fragment path, and three route highlights.
  • Select Back to all adventures.
  • Visit http://localhost:3000/adventures/not-real in your browser.
  • Confirm the custom not-found screen includes the Return to the catalog link.

That is the source boundary working end to end. The catalog and detail route now depend on the adapter while the guarded AEM Publish branch stays dormant.

Secret mission

Evolve the Content Model with Featured Adventures

Content models change as editorial teams add new ways to curate a catalog. Extend the adventure contract with a featured field, move the ordering rule into the adapter, and display a Featured badge without disrupting the detail routes.

Clean Up Your Resources

Clean Up Your Resources

The project runs entirely on your Mac with no ongoing costs. Choose whether to keep the development server active, pause it for later, or remove the workspace.

Resources you used:

  • A running Next.js development server for the aem-content-hub app.
  • The local aem-content-hub workspace with its source files and installed dependencies.

Keep everything running

No action is needed. Choose this option if you plan to continue developing or demonstrating the content hub.

  • Leave the development server running for your current session.
  • Keep the aem-content-hub folder with its adapter and local content fixture.
  • Visit http://localhost:3000 whenever you want to demonstrate the featured catalog and detail routes.

Pause - I'll come back to this later

Pausing stops the local development server while preserving every project file. You can resume from the same workspace later.

  • Return to the Terminal panel in VS Code that is running the development server.
  • Press Ctrl+C to stop the development server.
  • Leave the aem-content-hub folder on your Mac.

Your workspace is now paused. The terminal is free while the complete project remains ready for another session.

Delete - I don't want to use this again

Deleting gives you a clean slate by removing the local workspace. No cloud resources or external data need separate cleanup.

  • Return to the Terminal panel in VS Code that is running the development server.
  • Press Ctrl+C to stop the development server.

The terminal prompt returns when the server stops. This confirms that the running process has closed.

Deleting the workspace is permanent. The command only targets the aem-content-hub folder from this project.

  • Remove the aem-content-hub folder from its parent folder by running these commands:
cd ..
rm -rf aem-content-hub

What does this cleanup do?

  • The first command moves the terminal to the folder that contains aem-content-hub.
  • The second command permanently deletes aem-content-hub and everything inside it.
  • Confirm that the workspace is gone by running this command:
ls

Still see the project folder?

This command lists the contents of the current folder. A successful cleanup leaves aem-content-hub out of the results.

  • Check that the development server stopped before the deletion.
  • Run only the second cleanup command again while your terminal is in the folder that contains aem-content-hub.

Help me diagnose why the aem-content-hub folder was not removed.

Your local workspace is now fully removed. The project leaves no cloud resources behind.

Nice Work!

Nice Work!

Congratulations! Your NextWork Adventures hub now turns a locally simulated Adobe Experience Manager (AEM) Headless response into a responsive Next.js catalog.

You've learned how to:

  • Build a responsive adventure catalog from the typed data.adventureList.items content contract.
  • Create dynamic detail routes for every valid slug. Guide unknown slugs through a custom not-found experience back to the catalog.
  • Place source selection behind a typed server-side adapter. Surface the local source plus the persisted-query path while keeping remote AEM access disabled.
  • Secret Mission: Complete a Content Fragment model evolution with a required featured field. Keep ordering inside getAdventures() through copied-array sorting. Show Madeira Trail Circuit first with a Featured badge.

Ready to quiz yourself?