# Listing pages

The pieces of a page per listing: `getListing` and `getListingParams` to fetch and pre-render, `listingMetadata` for the title and social cards, `ListingDetail` and `ListingGallery` to show it, and `ListingSchema` for schema.org markup. The [Quickstart](https://sonor.dev/re-site-kit/quickstart) has a complete `app/listings/[slug]/page.tsx` built from them.

```tsx
import { getListing, getListingParams, listingMetadata } from '@sonordev/re-site-kit/server'
import { ListingDetail, ListingSchema, IdxAttribution } from '@sonordev/re-site-kit'
import { ListingGallery } from '@sonordev/re-site-kit/client'
```

## getListing

```ts
getListing(
  slug: string,
  options?: { site?: string },
  fetchOptions?: { revalidate?: number },
): Promise<RealEstateListing | null>
```

Fetches one listing by its slug. It returns `null` when the listing doesn't exist (or has left the listings your project shows), and also on any request failure, so pair it with `notFound()`:

```tsx
const listing = await getListing(slug)
if (!listing) notFound()
```

- `options.site`: on a multi-site project, the sub-site host the listing belongs to.
- `fetchOptions.revalidate`: the fetch cache window in seconds. Default 60.
- On a project in live serve mode, the listing can include `open_houses`, each with `date`, `start_time`, `end_time` and `comments`.

It's wrapped in React's `cache()`, so calling `getListing(slug)` in `generateMetadata` and again in the page makes one request.

## getListingParams

```ts
getListingParams(options?: { site?: string }): Promise<{ slug: string }[]>
```

Slugs for `generateStaticParams`, so listing pages are built ahead of time:

```ts
export async function generateStaticParams() {
  return getListingParams()
}
```

It asks for up to 1,000 active listings and keeps the ones with a slug. How many come back depends on Sonor's serve mode:

- **db mode:** up to 1,000, the most recently updated first.
- **live mode:** only the first page, at most 25 listings, since that's as many as the MLS returns at once. The kit doesn't page past it on purpose: in live mode every pre-built listing page is its own request to the MLS during your build.

Either way, leave `dynamicParams` at its default of `true`. Any listing that wasn't pre-built renders on its first visit and is cached after that, so a live-mode site pre-builds its 25 freshest listings and serves the rest on demand. The list itself is cached for 5 minutes. It returns `[]` on failure, which just means every page renders on demand.

## listingMetadata

```ts
listingMetadata(
  listing: RealEstateListing,
  options?: { titleSuffix?: string; url?: string },
): Metadata
```

Builds Next's `Metadata` for a detail page from the listing itself:

```ts
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params
  const listing = await getListing(slug)
  if (!listing) return {}
  return listingMetadata(listing, {
    titleSuffix: ' | Example Realty',
    url: `https://example.com/listings/${slug}`,
  })
}
```

| Field                  | Where it comes from                                                                                                                                                                |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title`                | `listing.seo_title` when it's set. Otherwise the street and location ("12 Oak St #4B, Springfield, IL 62701") followed by `titleSuffix`. The suffix isn't added to an `seo_title`. |
| `description`          | `listing.seo_description` when it's set. Otherwise the price, the specs and the first 155 characters of the description, joined with ". " and capped at 300 characters.            |
| `alternates.canonical` | `url`, when you pass it.                                                                                                                                                           |
| `openGraph`            | The same title and description, `type: 'website'`, `url` when given, and the listing's first four photos as images.                                                                |

Merge in anything else your site sets (Twitter cards, robots) with a spread: `{ ...listingMetadata(listing), robots: { index: true } }`.

## ListingDetail

The full listing view, rendered on the server with no client JavaScript.

```tsx
<ListingDetail listing={listing} />
```

| Prop         | Type                | Default | What it does                                                                                            |
| ------------ | ------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
| `listing`    | `RealEstateListing` |         | The listing to show. Required.                                                                          |
| `showPhotos` | `boolean`           | `true`  | Show the photo strip. Turn it off when the page shows the photos another way, such as `ListingGallery`. |
| `className`  | `string`            |         | Added next to `re-listing-detail`.                                                                      |

It renders an `<article>` with, in order:

1. **A photo strip** of up to six photos in a responsive grid, unless `showPhotos` is `false`. The first loads eagerly and the rest lazily.
2. **A header** with the street address as the page's `<h1>`, the location, the price and the specs ("3 bd · 2 ba · 1,960 sqft").
3. **Facts** as a `<dl>`, each shown only when the listing has it: Type, Year built, Lot, Garage, HOA (as "$250/Monthly", or "/mo" when the frequency is missing) and Days on market.
4. **The description.**
5. **An attribution line**, "Listed by Pat Agent, Example Realty", with a "View original listing" link to `source_url` when there's one.

Because `ListingDetail` renders the `<h1>`, don't add another one to the page.

**Using it with ListingGallery.** The strip and the gallery would show the same photos twice, so turn the strip off when you use the gallery:

```tsx
<ListingGallery images={listing.images ?? []} alt={listing.address} />
<ListingDetail listing={listing} showPhotos={false} />
```

## ListingGallery

A photo gallery for the detail page: the main photo with previous and next buttons, a photo counter, a thumbnail strip, and arrow-key navigation when the gallery has focus. It has no dependencies. It's a client component, so import it from `/client`.

```tsx
import { ListingGallery } from '@sonordev/re-site-kit/client'

<ListingGallery images={listing.images ?? []} alt={listing.address} />
```

| Prop             | Type       | Default           | What it does                                                                                           |
| ---------------- | ---------- | ----------------- | ------------------------------------------------------------------------------------------------------ |
| `images`         | `string[]` |                   | Photo URLs. Required. With none, the gallery renders nothing.                                          |
| `alt`            | `string`   | `"Listing photo"` | The base of each photo's alt text, usually the street address. Photos read "12 Oak St, photo 3 of 18". |
| `showThumbnails` | `boolean`  | `true`            | Show the thumbnail strip. It only appears when there are two or more photos.                           |
| `className`      | `string`   |                   | Added next to `re-gallery`.                                                                            |

The gallery takes keyboard focus so the arrow keys work, and shows the browser's focus ring when it has it (keyboard focus only, not mouse clicks). Restyle the ring with `.re-gallery:focus-visible` (see [Styling and performance](https://sonor.dev/re-site-kit/styling#focus-styles)), but keep one.

The main photo loads eagerly (it's usually the largest thing on the page) and thumbnails load lazily. Photos are plain `<img>` tags hotlinked from the MLS's image host. Those URLs change as homes sell, so the kit doesn't copy or re-host them, and there's no image optimization step to configure.

## ListingSchema

Adds schema.org `RealEstateListing` JSON-LD to the page, with no client JavaScript. Render it once per listing page.

```tsx
<ListingSchema
  listing={listing}
  url={`https://example.com/listings/${listing.slug}`}
  siteName="Example Realty"
/>
```

| Prop       | Type                | What it does                                                                                 |
| ---------- | ------------------- | -------------------------------------------------------------------------------------------- |
| `listing`  | `RealEstateListing` | The listing. Required.                                                                       |
| `url`      | `string`            | The page's canonical absolute URL. Used as the listing's `url` and the offer's `url`.        |
| `siteName` | `string`            | Your brokerage's name, used as the `provider` when the listing has no `listing_office_name`. |

The markup is built by `buildListingJsonLd` and escaped so listing text can't close the `<script>` tag. [Formatting and JSON-LD](https://sonor.dev/re-site-kit/formatting#buildlistingjsonld) lists every property it sets.

## IdxAttribution

The courtesy line and data-freshness timestamp most MLS agreements require on a listing page. Put it on every detail page. It has its own page: [IDX compliance](https://sonor.dev/re-site-kit/idx).

## Revalidation

A detail page with `export const revalidate = 60` is rebuilt at most once a minute, and `getListing` caches its fetch for the same 60 seconds. On a project in live serve mode that keeps the page within a minute or two of the MLS. If your site has Next's Cache Components turned on, leave the `revalidate` export out (Next rejects it there) and let the fetch windows do the work.
