---
title: "Workshop Pages"
description: "Give each workshop its own page with fx. Follow the connection between a Next.js route file and the address visitors open."
canonical_url: "https://vercel.com/academy/build-and-launch-with-ai/workshop-pages"
md_url: "https://vercel.com/academy/build-and-launch-with-ai/workshop-pages.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-09-25T16:42:53.938Z"
content_type: "lesson"
course: "build-and-launch-with-ai"
course_title: "Build and Launch with AI"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Workshop Pages

# Workshop pages

The catalog helps someone choose a workshop. Once they've chosen gardening, they'll want to know what happens during those two hours. We'll give each workshop a page they can open and share.

From a clean working tree in the website folder, create a branch for the interest feature:

```sh
git status
git switch -c workshop-interest
```

This branch lets us save the new feature's changes separately from the published version. We'll build the pages and form here, then review them before merging into the production branch in 4.7. Stay on this branch through Section 4.

## Outcome

Create a detail page for each workshop, link the cards to those pages, and handle unknown workshop addresses.

## Hands-on exercise 4.1

### Follow a workshop from card to page

Focus on the connection between a workshop's address and its record in the catalog. By the end, you should be able to explain why the gardening link opens gardening and what happens when the address names a workshop that doesn't exist. fx can write the route syntax; we'll review those connections and test them in the browser.

The complete route below is an optional reference for comparing or repairing the generated code. Keep your site's layout when it already works.

### Use the workshop data

Ask fx to find the workshop records and the card component. The reference stores records in `lib/workshops.ts`. Each record has a `slug`, a URL-friendly identifier such as `a-garden-in-a-pot`.

We want the homepage card and detail page to use the same facts. Copying the $45 price into several page files would make a later price change harder to review. A shared record lets both places read the same value.

Start fx in the project and use `/permissions ask`. Give it this request:

```text
Add detail pages for the two existing workshops. Use the current
workshop records as the source for titles, duration, price, and
other facts. Add descriptions and host information where needed,
keeping these fictional examples and dates unannounced.
Use an App Router route at app/workshops/[slug]/page.tsx, or the
existing src/app equivalent. Link each catalog card to its page.
Handle an unknown slug with notFound() and a link to the catalog.
Preserve the current design. Do not add a form, packages, or email
code yet. Do not commit, push, deploy, or read credential files.
```

Review its changes against the existing data. If the generated catalog uses inline records, consolidating those records may be part of this edit. Keep both original workshops; bookbinding arrives in Section 5.

### Read the route

In `app/workshops/[slug]/page.tsx`, square brackets mark a *dynamic segment*. The route uses the address's last segment to choose a workshop. For `/workshops/a-garden-in-a-pot`, the slug is `a-garden-in-a-pot`.

Find the code that reads the slug and passes it to `getWorkshop`. If the lookup finds no record, `notFound()` should show the missing-page message. Ask fx to point to those lines in your project, then compare the slug in each card's link with its workshop record.

Advanced: complete route and Next.js helpers (optional)

Next.js supplies `params` asynchronously, so this route awaits it. The [dynamic route documentation](https://nextjs.org/docs/app/api-reference/file-conventions/dynamic-routes) explains the file convention.

This is the reference route before the form exists:

```tsx
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { workshops, getWorkshop } from "@/lib/workshops";

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

export function generateStaticParams() {
  return workshops.map((workshop) => ({ slug: workshop.slug }));
}

export async function generateMetadata({ params }: WorkshopPageProps): Promise<Metadata> {
  const { slug } = await params;
  const workshop = await getWorkshop(slug);
  if (!workshop) notFound();

  return { title: workshop.title, description: workshop.summary };
}

export default async function WorkshopPage({ params }: WorkshopPageProps) {
  const { slug } = await params;
  const workshop = await getWorkshop(slug);
  if (!workshop) notFound();

  return (
    <div className={`detail-page shell accent-${workshop.accent}`}>
      <Link className="text-link back-link" href="/#workshops"><span aria-hidden="true">←</span> All workshops</Link>
      <header className="detail-header">
        <p className="eyebrow">The workshop notebook / {workshop.category}</p>
        <h1>{workshop.title}</h1>
        <p className="detail-summary">{workshop.summary}</p>
        <p className="host-line">With {workshop.host}</p>
      </header>
      <div className="detail-grid">
        <div className="detail-content">
          <div className="workshop-art detail-art" aria-hidden="true">
            <span className="art-grid" /><span className="art-disc" /><span className="art-arch" /><span className="art-line" />
            <span className="art-caption">Small Hours / A study in curiosity</span>
          </div>
          <section className="detail-section" aria-labelledby="workshop-about">
            <p className="eyebrow">A closer look</p>
            <h2 id="workshop-about">An idea worth an afternoon.</h2>
            <p className="workshop-description">{workshop.description}</p>
          </section>
          <section className="detail-section" aria-labelledby="takeaways-title">
            <h2 id="takeaways-title">What you’ll take away</h2>
            <ul className="takeaways">
              {workshop.takeaways.map((takeaway, index) => (
                <li key={takeaway}><span aria-hidden="true">{String(index + 1).padStart(2, "0")}</span><p>{takeaway}</p></li>
              ))}
            </ul>
          </section>
        </div>
        <aside className="interest-panel" aria-label="Workshop facts">
          <p className="eyebrow">The particulars</p>
          <dl className="workshop-facts">
            <div><dt>Date</dt><dd>{workshop.dateLabel}</dd></div>
            <div><dt>Duration</dt><dd>{workshop.duration}</dd></div>
            <div><dt>Format</dt><dd>{workshop.format}</dd></div>
            <div><dt>Example price</dt><dd>{workshop.price}</dd></div>
          </dl>

        </aside>
      </div>
      <div className="detail-end"><p>Another idea on your mind?</p><Link className="text-link" href="/#workshops">Explore all workshops <span aria-hidden="true">↗</span></Link></div>
    </div>
  );
}
```

`generateStaticParams` lists known workshops for page generation. `generateMetadata` sets each page's browser title and description. The main function uses the selected record for either workshop.

The reference already has the `Workshop` type and `getWorkshop` lookup in `lib/workshops.ts`. For a generated project, have fx add any missing fields and that lookup while preserving the original card facts. Confirm its imports match the file paths it created.

### Connect the cards

Import `Link` from `next/link` in the card component. Wrap the title and add an “Explore workshop” link using `/workshops/` followed by the current record's slug. The solution below shows the changed markup.

Create `app/not-found.tsx` with a short missing-page message and a link to `/#workshops`. Keep the shared header and footer in `app/layout.tsx`; the detail route will use that layout automatically.

## Try It

Start the local server if needed. Open both cards, then copy the gardening detail URL into a fresh tab and reload it. The page should still show gardening's two-hour duration and $45 example price.

Visit `/workshops/not-a-workshop` on the local address. It should show the missing-page UI and a usable path back to the catalog. Check the browser title on each valid detail page too.

### Clicking works, but reloading returns a missing page

Check that the route lives in the active `app` or `src/app` tree and is named `page.tsx`. Showing workshop details within the homepage doesn't give them a URL that can open on its own. Compare the link's slug with the record used by the lookup.

### Every card opens the same workshop

Inspect the link inside the repeated card component. It should read the current `workshop.slug`. A hard-coded gardening URL will send every card to gardening, regardless of the displayed title.

## Commit

Exit fx, inspect the diff, and stage the route, card, missing-page UI, and any reviewed data or styling changes. For the reference paths:

```sh
git add 'app/workshops/[slug]/page.tsx' app/not-found.tsx components/workshop-card.tsx
git diff --cached
git commit -m "feat(workshops): add individual workshop pages"
```

Quote the route path so the shell treats its brackets as literal characters. Add any other intentionally changed file by its exact path before committing. Keep the branch local while we build the form.

## Done-When

- [ ] Each card opens its own workshop page.
- [ ] Direct navigation and reload work for both pages.
- [ ] Card and detail facts agree.
- [ ] An unknown slug shows a missing-page message with a catalog link.

## Solution

The route above is the complete detail-page implementation for this checkpoint. In `components/workshop-card.tsx`, add this import:

```tsx
import Link from "next/link";
```

Use the record's slug for the title link:

```tsx
<h3><Link href={`/workshops/${workshop.slug}`}>{workshop.title}</Link></h3>
```

Inside `card-bottom`, keep the existing price and use this link for the details action:

```tsx
<Link className="text-link" href={`/workshops/${workshop.slug}`} aria-label={`Explore ${workshop.title}`}>
  Explore workshop <span aria-hidden="true">↗</span>
</Link>
```

The missing-page component belongs in `app/not-found.tsx`:

```tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <section className="not-found shell" aria-labelledby="not-found-title">
      <p className="eyebrow">404 / A loose page</p>
      <h1 id="not-found-title">This page wandered<br />out of the notebook.</h1>
      <p>We couldn’t find that page. Head back to the workshop list to find something to explore.</p>
      <Link className="button" href="/#workshops">Back to workshops <span aria-hidden="true">↗</span></Link>
    </section>
  );
}
```

Both cards now link to their workshop's detail page. Check both known slugs and the missing-page case before saving the commit.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
