1 OAK MLS

Typesense Search

Per-workspace collection schema and search configuration for Typesense

Typesense Schema & Search Configuration

Per-workspace collection for faceted listing search.

Collection Strategy

Recommended for V1: One collection per workspace

  • Collection name: listings_{workspace_slug} (e.g., listings_lainey-levin)
  • Benefits: Complete isolation, easier to delete/rebuild per client
  • Tradeoff: Can't search across workspaces (not needed for V1)

Alternative: Single shared collection with workspace_id facet

  • Use if cross-workspace search needed later

Collection Schema

{
  "name": "listings_lainey-levin",
  "fields": [
    { "name": "id", "type": "string" },
    { "name": "workspace_id", "type": "string", "facet": true },
    { "name": "mls_number", "type": "string", "optional": true },

    { "name": "standard_status", "type": "string", "facet": true },
    { "name": "is_active", "type": "bool", "facet": true },
    { "name": "property_type", "type": "string", "facet": true },

    { "name": "city", "type": "string", "facet": true },
    { "name": "subdivision", "type": "string", "facet": true },
    { "name": "building_name", "type": "string", "facet": true, "optional": true },
    { "name": "postal_code", "type": "string", "facet": true },
    { "name": "state", "type": "string", "facet": true, "optional": true },
    { "name": "county", "type": "string", "facet": true, "optional": true },
    { "name": "community", "type": "string", "facet": true, "optional": true },

    { "name": "city_normalized", "type": "string", "facet": true, "optional": true },
    { "name": "subdivision_slug", "type": "string", "facet": true, "optional": true },
    { "name": "building_name_normalized", "type": "string", "facet": true, "optional": true },

    { "name": "list_price", "type": "int64", "facet": true },
    { "name": "bedrooms", "type": "int32", "facet": true },
    { "name": "bathrooms", "type": "float", "facet": true },
    { "name": "living_area_sqft", "type": "int32", "optional": true },
    { "name": "lot_size_sqft", "type": "int32", "optional": true },
    { "name": "year_built", "type": "int32", "facet": true, "optional": true },
    { "name": "price_per_sqft", "type": "float", "optional": true },

    { "name": "location", "type": "geopoint", "optional": true },

    { "name": "address_full", "type": "string" },
    { "name": "public_remarks", "type": "string", "optional": true },

    { "name": "photo_count", "type": "int32", "optional": true },
    { "name": "primary_photo_url", "type": "string", "optional": true },

    { "name": "list_date_ts", "type": "int64", "optional": true },
    { "name": "modification_ts", "type": "int64" },

    { "name": "import_category", "type": "string", "facet": true, "optional": true }
  ],
  "default_sorting_field": "modification_ts",
  "enable_nested_fields": false
}

Field Notes

FieldPurposeNotes
idDocument IDUse listing's Postgres UUID
workspace_idTenant filterAlways filter by this
mls_numberMLS identifierPublic MLS number for display
standard_statusStatus facetActive, Pending, Closed, etc.
is_activeActive filterBoolean for quick filtering
property_typeType facetCondominium, Single Family, etc.
cityLocation facetPrimary location filter
subdivisionNeighborhood facetImportant for luxury market
building_nameBuilding facetFor condos — optional
postal_codeZip code facetSecondary location filter
stateState facetState/province
countyCounty facetCounty name
communityCommunity facetCommunity/HOA name
city_normalizedService area filterSlugified city name for matching
subdivision_slugService area filterURL-safe subdivision identifier
building_name_normalizedService area filterLowercase building name
list_pricePrice facet/sortStore as int64 (dollars)
bedroomsBeds facetInteger
bathroomsBaths facetFloat for half-baths
living_area_sqftSize facetOptional — not all listings have this
lot_size_sqftLot sizeLand area in square feet
year_builtYear facetConstruction year
price_per_sqftCalculatedList price / living area
locationGeo searchGeopoint for radius search
address_fullText searchSearchable address
public_remarksText searchDescription text
photo_countDisplayNumber of photos available
primary_photo_urlDisplayPrimary photo for results grid
list_date_tsDate filterList date as Unix timestamp
modification_tsSort fieldUnix timestamp for "newest first"
import_categorySource filterpersonal, office, search, or custom

Normalized Location Fields

The *_normalized fields enable service area filtering:

// Normalization function
function normalizeForSearch(value: string): string {
  return value
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');
}

// Example: "Miami Beach" → "miami-beach"

Import Category Field

The import_category field tracks the source of each listing:

ValueDescriptionTypical Filter
personalAgent's own listingsAgent MLS ID
officeBrokerage listingsOffice MLS ID
searchService area importsGeographic filters
customAd-hoc importsCustom OData query

Use for "My Listings" filtering:

filter_by: import_category:=[personal,office]

Search Configuration

Query Fields

query_by: address_full,city,subdivision,building_name,postal_code,public_remarks

Facets to Display

Configurable per workspace, but defaults:

facet_by: city,subdivision,building_name,property_type,standard_status,bedrooms,bathrooms,list_price

Sort Options

const SORT_OPTIONS = [
  { label: 'Newest', value: 'modification_ts_unix:desc' },
  { label: 'Price: Low to High', value: 'list_price:asc' },
  { label: 'Price: High to Low', value: 'list_price:desc' },
  { label: 'Beds: Most First', value: 'bedrooms:desc' },
  { label: 'Size: Largest First', value: 'living_area_sqft:desc' },
];

Filter Examples

Status filter (usually always applied):

filter_by: workspace_id:=lainey-levin && standard_status:=Active

Price range:

filter_by: list_price:>=[500000] && list_price:<=[1000000]

Bedrooms:

filter_by: bedrooms:>=[3]

City + Property Type:

filter_by: city:=[Miami Beach, Coral Gables] && property_type:=Condominium

Combined:

filter_by: workspace_id:=lainey-levin && standard_status:=Active && city:=Miami Beach && bedrooms:>=[2] && list_price:<=[2000000]

Indexing from Postgres

Document Transform

interface TypesenseListingDoc {
  id: string;
  workspace_id: string;
  standard_status: string;
  property_type: string;
  city: string;
  subdivision: string;
  building_name?: string;
  postal_code: string;
  list_price: number;
  bedrooms: number;
  bathrooms: number;
  living_area_sqft?: number;
  latitude?: number;
  longitude?: number;
  address_full: string;
  public_remarks?: string;
  photo_url?: string;
  modification_ts_unix: number;
}

function toTypesenseDoc(
  listing: Listing,
  workspace: Workspace,
  primaryPhoto?: string
): TypesenseListingDoc {
  return {
    id: listing.id,
    workspace_id: workspace.slug,
    standard_status: listing.standard_status || 'Unknown',
    property_type: listing.property_type || 'Unknown',
    city: listing.city || '',
    subdivision: listing.subdivision || '',
    building_name: listing.building_name || undefined,
    postal_code: listing.postal_code || '',
    list_price: listing.list_price || 0,
    bedrooms: listing.bedrooms || 0,
    bathrooms: listing.bathrooms || 0,
    living_area_sqft: listing.living_area_sqft || undefined,
    latitude: listing.latitude || undefined,
    longitude: listing.longitude || undefined,
    address_full: listing.address_full || '',
    public_remarks: listing.public_remarks || undefined,
    photo_url: primaryPhoto || undefined,
    modification_ts_unix: listing.modification_ts
      ? Math.floor(new Date(listing.modification_ts).getTime() / 1000)
      : Math.floor(Date.now() / 1000),
  };
}

Batch Indexing

import Typesense from 'typesense';

const client = new Typesense.Client({
  nodes: [{
    host: TYPESENSE_HOST,
    port: TYPESENSE_PORT,
    protocol: TYPESENSE_PROTOCOL
  }],
  apiKey: TYPESENSE_API_KEY,
  connectionTimeoutSeconds: 10,
});

async function indexListings(
  collectionName: string,
  docs: TypesenseListingDoc[]
) {
  // Batch in chunks of 40 for reliability
  const BATCH_SIZE = 40;

  for (let i = 0; i < docs.length; i += BATCH_SIZE) {
    const batch = docs.slice(i, i + BATCH_SIZE);

    await client
      .collections(collectionName)
      .documents()
      .import(batch, { action: 'upsert' });
  }
}

Collection Management

Create Collection

async function createListingsCollection(workspaceSlug: string) {
  const schema = {
    name: `listings_${workspaceSlug}`,
    fields: [
      { name: 'id', type: 'string' as const },
      { name: 'workspace_id', type: 'string' as const, facet: true },
      { name: 'standard_status', type: 'string' as const, facet: true },
      { name: 'property_type', type: 'string' as const, facet: true },
      { name: 'city', type: 'string' as const, facet: true },
      { name: 'subdivision', type: 'string' as const, facet: true },
      { name: 'building_name', type: 'string' as const, facet: true, optional: true },
      { name: 'postal_code', type: 'string' as const, facet: true },
      { name: 'list_price', type: 'int32' as const, facet: true },
      { name: 'bedrooms', type: 'int32' as const, facet: true },
      { name: 'bathrooms', type: 'float' as const, facet: true },
      { name: 'living_area_sqft', type: 'int32' as const, facet: true, optional: true },
      { name: 'latitude', type: 'float' as const, optional: true },
      { name: 'longitude', type: 'float' as const, optional: true },
      { name: 'address_full', type: 'string' as const },
      { name: 'public_remarks', type: 'string' as const, optional: true },
      { name: 'photo_url', type: 'string' as const, optional: true },
      { name: 'modification_ts_unix', type: 'int64' as const, sort: true },
    ],
    default_sorting_field: 'modification_ts_unix',
  };

  return client.collections().create(schema);
}

Delete Collection (for rebuild)

async function deleteListingsCollection(workspaceSlug: string) {
  return client.collections(`listings_${workspaceSlug}`).delete();
}

Generate Search-Only API Key

For client-side search (scoped to one collection):

async function createScopedSearchKey(workspaceSlug: string) {
  return client.keys().create({
    description: `Search key for ${workspaceSlug}`,
    actions: ['documents:search'],
    collections: [`listings_${workspaceSlug}`],
    expires_at: Math.floor(Date.now() / 1000) + (365 * 24 * 60 * 60), // 1 year
  });
}

Search API (Next.js Server Route)

// app/api/search/route.ts

import { NextRequest, NextResponse } from 'next/server';
import Typesense from 'typesense';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;

  const workspaceSlug = searchParams.get('workspace') || 'lainey-levin';
  const query = searchParams.get('q') || '*';
  const page = parseInt(searchParams.get('page') || '1');
  const perPage = parseInt(searchParams.get('per_page') || '24');
  const sortBy = searchParams.get('sort') || 'modification_ts_unix:desc';
  const filters = searchParams.get('filters') || '';

  const client = new Typesense.Client({
    nodes: [{
      host: process.env.TYPESENSE_HOST!,
      port: 443,
      protocol: 'https'
    }],
    apiKey: process.env.TYPESENSE_API_KEY!,
  });

  // Build filter string
  let filterBy = `workspace_id:=${workspaceSlug} && standard_status:=Active`;
  if (filters) {
    filterBy += ` && ${filters}`;
  }

  const results = await client
    .collections(`listings_${workspaceSlug}`)
    .documents()
    .search({
      q: query,
      query_by: 'address_full,city,subdivision,building_name,postal_code,public_remarks',
      filter_by: filterBy,
      sort_by: sortBy,
      facet_by: 'city,subdivision,building_name,property_type,bedrooms,bathrooms,list_price',
      max_facet_values: 50,
      page,
      per_page: perPage,
    });

  return NextResponse.json(results);
}

Facet Range Presets

For price slider / range filters:

const PRICE_RANGES = [
  { label: 'Under $500K', filter: 'list_price:<500000' },
  { label: '$500K - $1M', filter: 'list_price:>=[500000] && list_price:<[1000000]' },
  { label: '$1M - $2M', filter: 'list_price:>=[1000000] && list_price:<[2000000]' },
  { label: '$2M - $5M', filter: 'list_price:>=[2000000] && list_price:<[5000000]' },
  { label: '$5M+', filter: 'list_price:>=[5000000]' },
];

const BEDROOM_OPTIONS = [
  { label: 'Any', filter: '' },
  { label: '1+', filter: 'bedrooms:>=[1]' },
  { label: '2+', filter: 'bedrooms:>=[2]' },
  { label: '3+', filter: 'bedrooms:>=[3]' },
  { label: '4+', filter: 'bedrooms:>=[4]' },
  { label: '5+', filter: 'bedrooms:>=[5]' },
];

On this page