docs
    re-site-kit: Quickstart
    v0.6.0.md

    Quickstart

    This page wires a listings index and a listing detail page end to end: search, filters, paging, a detail view with a photo gallery, schema.org markup, IDX attribution, an inquiry form, and the view tracking that powers hot properties. Every file is complete, so you can paste them in order.

    Before you start

    • site-kit is mounted. @sonordev/site-kit 4.3.0 or later is installed and SiteKitLayout is in app/layout.tsx (see site-kit's Layout docs). re-site-kit's fetchers, analytics and forms all run through it.
    • SONOR_API_KEY is set in .env.local and in your host's environment. It's the same key site-kit reads, and it never reaches the browser through this kit.
    • Your Sonor project has listings. The Real Estate module is on and a feed is connected, so the API has something to return.

    If the key is missing, every fetcher logs a warning and returns an empty result (or null for a single listing) instead of throwing, so the pages still render, just with no listings.

    1. Install

    pnpm add @sonordev/re-site-kit

    2. Read the search parameters

    ListingFilters submits a plain GET form, so the filters arrive as URL parameters in snake_case (min_price, beds_min). searchListings takes camelCase options. This small helper does the translation once, and also flattens the parameters for ListingPagination, which carries them from page to page:

    // lib/listing-search.ts
    import type { SearchListingsOptions } from '@sonordev/re-site-kit'
    
    export type SearchParams = Record<string, string | string[] | undefined>
    
    const SORTS = ['price_asc', 'price_desc', 'newest', 'updated'] as const
    
    function first(value: string | string[] | undefined): string | undefined {
      const v = Array.isArray(value) ? value[0] : value
      return v?.trim() || undefined
    }
    
    function positive(value: string | string[] | undefined): number | undefined {
      const n = Number(first(value))
      return Number.isFinite(n) && n > 0 ? n : undefined
    }
    
    /**
     * Turns the page's searchParams into searchListings options, plus the flat
     * query ListingPagination carries from page to page.
     */
    export function readListingSearch(params: SearchParams) {
      const sort = first(params.sort)
      const options: SearchListingsOptions = {
        q: first(params.q),
        city: first(params.city),
        minPrice: positive(params.min_price),
        maxPrice: positive(params.max_price),
        bedsMin: positive(params.beds_min),
        bathsMin: positive(params.baths_min),
        sort: SORTS.find((s) => s === sort),
        page: Math.floor(positive(params.page) ?? 1),
        limit: 12,
      }
      const query = Object.fromEntries(
        Object.entries(params).map(([key, value]) => [key, first(value)]),
      )
      return { options, query }
    }

    Because options uses the same shape as SearchListingsOptions, you can hand it straight to ListingFilters as values to keep the inputs filled in after a search.

    3. The listings index

    // app/listings/page.tsx
    import type { Metadata } from 'next'
    import { searchListings } from '@sonordev/re-site-kit/server'
    import { ListingFilters, ListingGrid, ListingPagination } from '@sonordev/re-site-kit'
    import { readListingSearch, type SearchParams } from '@/lib/listing-search'
    
    export const metadata: Metadata = { title: 'Homes for sale | Example Realty' }
    
    export default async function ListingsPage({
      searchParams,
    }: {
      searchParams: Promise<SearchParams>
    }) {
      const { options, query } = readListingSearch(await searchParams)
      const { listings, pagination } = await searchListings(options)
    
      return (
        <main>
          <h1>Homes for sale</h1>
          <ListingFilters basePath="/listings" values={options} showKeyword />
          <ListingGrid
            listings={listings}
            hrefFor={(listing) => (listing.slug ? `/listings/${listing.slug}` : null)}
            emptyState={<p>No homes match those filters. Try a wider price range.</p>}
          />
          <ListingPagination
            pagination={pagination}
            listingCount={listings.length}
            basePath="/listings"
            searchParams={query}
          />
        </main>
      )
    }

    The page reads searchParams, so Next renders it per request. listingCount lets the pagination offer a next page even when the total is unknown, which happens on some live-mode responses. The listing fetch itself is cached for 60 seconds, so most requests never wait on the API. showKeyword adds the search box, which takes an address, an MLS number, a city or a neighborhood. Search and filters covers every option.

    4. The listing detail page

    // app/listings/[slug]/page.tsx
    import type { Metadata } from 'next'
    import { notFound } from 'next/navigation'
    import { getListing, getListingParams, listingMetadata } from '@sonordev/re-site-kit/server'
    import { IdxAttribution, ListingDetail, ListingSchema } from '@sonordev/re-site-kit'
    import {
      ListingGallery,
      ListingInquiryForm,
      ListingViewTracker,
    } from '@sonordev/re-site-kit/client'
    
    const SITE_URL = 'https://example.com'
    
    type Props = { params: Promise<{ slug: string }> }
    
    export const revalidate = 60
    
    export async function generateStaticParams() {
      return getListingParams()
    }
    
    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: `${SITE_URL}/listings/${slug}`,
      })
    }
    
    export default async function ListingPage({ params }: Props) {
      const { slug } = await params
      const listing = await getListing(slug)
      if (!listing) notFound()
    
      return (
        <main>
          <ListingSchema
            listing={listing}
            url={`${SITE_URL}/listings/${slug}`}
            siteName="Example Realty"
          />
          <ListingGallery images={listing.images ?? []} alt={listing.address} />
          {/* The gallery shows the photos, so leave them out of the detail view. */}
          <ListingDetail listing={listing} showPhotos={false} />
          <IdxAttribution listing={listing} />
          <ListingInquiryForm
            formId="listing-inquiry"
            listing={{
              slug: listing.slug,
              source_listing_id: listing.source_listing_id,
              address: listing.address,
              price: listing.price,
            }}
          />
          <ListingViewTracker slug={listing.slug} listingKey={listing.source_listing_id} />
        </main>
      )
    }

    A few things worth knowing about this page:

    • getListing(slug) runs in both generateMetadata and the page, but it's wrapped in React's cache(), so it's one request.
    • ListingDetail renders the listing's <h1>, so the page doesn't add another.
    • ListingInquiryForm is a client component. Passing it only the four fields it needs keeps the rest of the listing out of the page's client payload.
    • ListingViewTracker renders nothing. It's a childless sibling, never a wrapper, so it can't turn the page into a client-rendered one.

    If your site has Next's Cache Components turned on, leave out the revalidate export: Next rejects route segment config there, and the kit's fetches carry their own revalidate times. Listing pages has the details on each piece.

    5. Set up the inquiry form in Sonor

    formId="listing-inquiry" points at a managed form in your Sonor project. Create a prospect form with that slug, and add three hidden fields with the slugs listing_slug, listing_key and listing_address so each lead lands in the CRM attached to its listing. Inquiry forms walks through it.

    6. Check it

    Build and start the site, then confirm the pages are server-rendered with real content (not just the RSC payload):

    # How many listing cards are in the HTML
    curl -s http://localhost:3000/listings | grep -o 'class="re-listing-card"' | wc -l
    # The detail page's heading (use a slug from the index)
    curl -s http://localhost:3000/listings/<slug> | grep -o '<h1[^>]*>[^<]*'

    Zero cards on a project with listings usually means SONOR_API_KEY isn't set where the server can read it. Check the server log for a Missing SONOR_API_KEY warning.

    Next steps