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.
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.
Search
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.