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
| Field | Purpose | Notes |
|---|---|---|
id | Document ID | Use listing's Postgres UUID |
workspace_id | Tenant filter | Always filter by this |
mls_number | MLS identifier | Public MLS number for display |
standard_status | Status facet | Active, Pending, Closed, etc. |
is_active | Active filter | Boolean for quick filtering |
property_type | Type facet | Condominium, Single Family, etc. |
city | Location facet | Primary location filter |
subdivision | Neighborhood facet | Important for luxury market |
building_name | Building facet | For condos — optional |
postal_code | Zip code facet | Secondary location filter |
state | State facet | State/province |
county | County facet | County name |
community | Community facet | Community/HOA name |
city_normalized | Service area filter | Slugified city name for matching |
subdivision_slug | Service area filter | URL-safe subdivision identifier |
building_name_normalized | Service area filter | Lowercase building name |
list_price | Price facet/sort | Store as int64 (dollars) |
bedrooms | Beds facet | Integer |
bathrooms | Baths facet | Float for half-baths |
living_area_sqft | Size facet | Optional — not all listings have this |
lot_size_sqft | Lot size | Land area in square feet |
year_built | Year facet | Construction year |
price_per_sqft | Calculated | List price / living area |
location | Geo search | Geopoint for radius search |
address_full | Text search | Searchable address |
public_remarks | Text search | Description text |
photo_count | Display | Number of photos available |
primary_photo_url | Display | Primary photo for results grid |
list_date_ts | Date filter | List date as Unix timestamp |
modification_ts | Sort field | Unix timestamp for "newest first" |
import_category | Source filter | personal, 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:
| Value | Description | Typical Filter |
|---|---|---|
personal | Agent's own listings | Agent MLS ID |
office | Brokerage listings | Office MLS ID |
search | Service area imports | Geographic filters |
custom | Ad-hoc imports | Custom 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_remarksFacets to Display
Configurable per workspace, but defaults:
facet_by: city,subdivision,building_name,property_type,standard_status,bedrooms,bathrooms,list_priceSort 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:=ActivePrice 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:=CondominiumCombined:
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]' },
];