Directories

Building a directory with Next.js

We earn commissions when you shop through the links below.

Model listings and categories in Prisma and render public, SEO-friendly listing pages in the Next.js App Router.

2 min read

A directory is a list of records with their own URLs. The Directory Starter uses Next.js 15, Prisma 6, and SQLite. Search indexing for a large catalog is a separate problem, covered in SEO architecture for large Next.js directories.

Models

model Category {
  id       String    @id @default(cuid())
  slug     String    @unique
  name     String
  listings Listing[]
}

model Listing {
  id         String   @id @default(cuid())
  slug       String   @unique
  title      String
  summary    String
  url        String
  categoryId String
  category   Category @relation(fields: [categoryId], references: [id])
  status     String   @default("pending")
  createdAt  DateTime @default(now())
}

slug is the public id. status defaults to pending, so a submission is not public until you publish it. The listing page loads by slug and calls notFound() unless status === "published".

The listing route

src/app/listings/[slug]/page.tsx is an async server component. In Next.js 15 the params prop is a promise:

export default async function ListingPage({ params }: { params: Promise<{ slug: string }> }) {
  const listing = await prisma.listing.findUnique({
    where: { slug: (await params).slug },
    include: { category: true }
  });
  if (!listing || listing.status !== "published") notFound();
  return (
    <main>
      <p>{listing.category.name}</p>
      <h1>{listing.title}</h1>
      <p>{listing.summary}</p>
      <a href={listing.url}>Visit</a>
    </main>
  );
}

generateMetadata uses the same lookup and returns title and description from the row. A missing listing still needs a fallback title because metadata runs even when you later call notFound().

Submission

The home page lists published rows and offers a submit form. New rows stay pending. Review them before they get a public URL. The kit does not take payment for a listing. A paid submission flow is what the Job Board Starter adds for jobs; you can copy that Checkout pattern if a directory listing should be paid.

Filtering is a query against title and category. Do it in Prisma (contains on SQLite is case-sensitive for some builds; test the collation you care about). Do not load every listing into the client and filter there once the table is large.

Filters and listing pages

The index should accept a category slug and a search string as URL search params, not only as React state. That keeps a filtered view shareable and indexable. Each listing still needs its own /listings/[slug] URL. Do not fold the detail view into a modal if you care about search.

generateMetadata on the listing route is the whole SEO surface for that record: title, description, and later canonical. The sitemap article shows how to list published slugs.

Related: directory database schema and dynamic sitemaps.

Want to skip the setup? The Directory Starter already includes categories, listing pages, search, and a pending submission state.

Product updates for developers shipping with Designyff kits.

Guides: Next.js, Stripe, and SaaS starter kits Professional website design for small businesses

Purchased source may be used in personal, commercial, and client projects. Do not redistribute or resell the original source. Terms of Service Privacy Policy

Designyff © 2026 | Starter kits for developers