
Integrating the APIMO API in Next.js: From Raw Data to Real Estate Listing
APIMO is a SaaS platform for managing real estate agencies that exposes a REST API to access the entire property catalog. I used this API to build the Scano Immobiliare website, an agency operating between Italy and France. If your agency uses APIMO and you want to expose your listings on a Next.js site, this guide shows you exactly how: authentication, fetch with caching, TypeScript typing, and mapping raw data into readable information.
1. Authentication: Basic Auth with Encoded Credentials
APIMO uses HTTP Basic Authentication. Credentials are the PROVIDER_ID and TOKEN, which must be combined in a provider:token string, converted to Base64, and passed in the Authorization header. Credentials remain strictly server-side, inside environment variables.
// lib/api.ts
const provider = process.env.APIMO_PROVIDER_ID || "";
const token = process.env.APIMO_TOKEN || "";
const headers = {
Authorization: `Basic ${Buffer.from(`${provider}:${token}`).toString("base64")}`,
};2. The Endpoints and Fetching with ISR
APIMO has two main endpoints: GET /agencies/{agencyId}/properties for the complete list and GET /agencies/{agencyId}/properties/{propertyId} for a single property. Wrap them in an api object that centralizes all calls.
// lib/api.ts
export const api = {
properties: {
getAll: () =>
fetchHandler<PropertyResponse>(
`${API_BASE_URL}/agencies/${AGENCY_ID}/properties`,
{ headers, next: { revalidate: 60 } }
),
getOne: (propertyId: string) =>
fetchHandler<Property>(
`${API_BASE_URL}/agencies/${AGENCY_ID}/properties/${propertyId}`,
{ headers, next: { revalidate: 60 } }
),
},
};The next: { revalidate: 60 } leverages Next.js ISR: the response is cached and regenerated every 60 seconds, without calling APIMO on every page load. For properties that change frequently, reduce the value; for stable catalogs, increase it.
The fetchHandler in lib/handlers/fetch.ts adds timeout with AbortController, error handling, and logging. Always returns a consistent ActionResponse<T> type.
// lib/handlers/fetch.ts
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout); // default 5s
try {
const response = await fetch(url, { ...config, signal: controller.signal });
if (!response.ok) throw new RequestError(response.status, `HTTP error: ${response.status}`);
const data: T = await response.json();
return { success: true, data };
} finally {
clearTimeout(id);
}3. Typing the Response: From JSON to TypeScript
APIMO returns a structured response with metadata and an array of properties. TypeScript typing is fundamental to avoid errors and enable IDE autocomplete.
// types/property.d.ts
export interface PropertyResponse {
total_items: number;
timestamp: number;
properties: Property[];
}
export interface Property {
id: string;
category: number; // 1 = sale, 2 = rent
type: number; // 1 = apartment, 2 = villa
subtype?: number;
price: {
value: number;
max?: number;
currency: string;
hide?: boolean;
period?: number;
};
pictures: Picture[];
comments: LocalizedComment[];
areas: AreaDetail[];
regulations: Regulation[];
city?: { name: string };
}
export interface Picture {
id: string;
type_id: number;
rank: number;
url: string;
width: number;
height: number;
}
export interface LocalizedComment {
language: "it" | "en";
title?: string;
subtitle?: string;
comment?: string;
}
export interface AreaDetail {
type: number;
number: number;
area: number;
flooring?: number;
orientations?: number[];
}
export interface Regulation {
type: number; // 13 = energy class
value: string;
}4. Mapping with Constants
Each numeric ID returned by APIMO is a code that must be translated into a readable label. The semantics of IDs are documented by APIMO, but you need a constant map for each domain.
// constants/propertyType.ts
export const PROPERTY_TYPES: Record<number, string> = {
1: "Apartment",
2: "Villa",
3: "House",
4: "Studio",
5: "Land",
};
// constants/heatingType.ts
export const HEATING_TYPES: Record<number, string> = {
1: "Radiators",
2: "Underfloor",
3: "Heat pump",
4: "Fireplace",
};
// constants/areasType.ts
export const AREAS_TYPES: Record<number, string> = {
1: "Living room",
2: "Kitchen",
8: "Bathroom",
13: "Shower",
41: "Guest bathroom",
42: "Dressing room",
20: "Bedroom",
21: "Garage",
22: "Cellar",
};Create utility files in lib/ to extract and format data. These helpers isolate mapping logic and make it reusable throughout the app.
// lib/property-utils.ts
import { AREAS_TYPES, HEATING_TYPES } from "@/constants";
import type { Property } from "@/types/property";
export const getEnergyClass = (property: Property) => {
// Regulations with type === 13 contains the energy class (A, B, C…)
const regulation = property.regulations?.find((r) => r.type === 13);
return regulation?.value && regulation.value !== "0" ? regulation.value : null;
};
export const getBathroomsCount = (property: Property) => {
// Sum all rooms of type bathroom/shower/dressing
const BATHROOM_TYPES = [8, 13, 41, 42];
return property.areas
?.filter((a) => BATHROOM_TYPES.includes(a.type))
.reduce((sum, a) => sum + a.number, 0) || 0;
};
export const getBedroomsCount = (property: Property) => {
// Sum bedrooms (type 20)
return property.areas
?.filter((a) => a.type === 20)
.reduce((sum, a) => sum + a.number, 0) || 0;
};
export const getGarageCount = (property: Property) => {
// Sum garages (type 21)
return property.areas
?.filter((a) => a.type === 21)
.reduce((sum, a) => sum + a.number, 0) || 0;
};
export const getFormattedPrice = (property: Property, locale: "it" | "en") => {
if (!property.price?.value) return null;
return property.price.value.toLocaleString(
locale === "it" ? "it-IT" : "en-US",
{
style: "currency",
currency: property.price.currency,
maximumFractionDigits: 0,
}
);
};
export const getTitleForLanguage = (
property: Property,
locale: "it" | "en"
) => {
return property.comments?.find((c) => c.language === locale)?.title || "";
};
export const getMainImage = (property: Property) => {
// Returns the image with the lowest rank (usually the first)
return property.pictures?.sort((a, b) => a.rank - b.rank)[0] || null;
};5. Client-Side Filtering on Aggregated Data
APIMO does not offer server-side pagination in basic calls, so it returns all properties in a single response. Filtering happens on the client after fetch. If the catalog is very large (1000+), consider server-side filtering or pagination.
// components/properties-list/index.tsx
import { useMemo } from "react";
import type { Property } from "@/types/property";
import { getFormattedPrice, getTitleForLanguage, getMainImage } from "@/lib/property-utils";
interface FiltersProps {
deal?: "sale" | "rent";
priceMin?: number;
priceMax?: number;
search?: string;
bedroomsMin?: number;
}
export const PropertiesList = ({
properties,
filters,
locale,
}: {
properties: Property[];
filters?: FiltersProps;
locale: "it" | "en";
}) => {
const filtered = useMemo(() => {
let result = [...(properties || [])];
// Filter by category (sale/rent)
if (filters?.deal) {
const category = filters.deal === "sale" ? 1 : 2;
result = result.filter((p) => p.category === category);
}
// Filter by price range
if (filters?.priceMin) {
result = result.filter((p) => (p.price?.value || 0) >= filters.priceMin!);
}
if (filters?.priceMax) {
result = result.filter((p) => (p.price?.value || 0) <= filters.priceMax!);
}
// Full-text search on title, city, address
if (filters?.search) {
const q = filters.search.toLowerCase();
result = result.filter((p) => {
const title = getTitleForLanguage(p, locale).toLowerCase();
const city = p.city?.name?.toLowerCase() || "";
return title.includes(q) || city.includes(q);
});
}
return result;
}, [properties, filters, locale]);
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{filtered.map((property) => (
<PropertyCard key={property.id} property={property} locale={locale} />
))}
</div>
);
};
const PropertyCard = ({
property,
locale,
}: {
property: Property;
locale: "it" | "en";
}) => {
const image = getMainImage(property);
const title = getTitleForLanguage(property, locale);
const price = getFormattedPrice(property, locale);
return (
<article className="border rounded-lg overflow-hidden shadow-md hover:shadow-lg transition">
{image && (
<img
src={image.url}
alt={title}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<h3 className="font-bold text-lg mb-1">{title}</h3>
<p className="text-gray-600 text-sm mb-3">{property.city?.name}</p>
<p className="text-xl font-bold text-green-600">{price}</p>
</div>
</article>
);
};If the catalog exceeds 500-1000 items, consider implementing pagination (with custom offset/limit) or server-side filtering: the DOM will stay agile and filtering will be faster.
6. Implementation in a Next.js Page
In the page layout, fetch data with getAll(), pass the result to the list component, and use the searchParams parameter to read filters from the URL. ISR handles caching automatically.
// app/immobili/page.tsx
import { api } from "@/lib/api";
import { PropertiesList } from "@/components/properties-list";
import { Suspense } from "react";
export const metadata = {
title: "Properties for sale and rent",
description: "Discover our complete property catalog",
};
interface SearchParams {
deal?: string;
minPrice?: string;
maxPrice?: string;
search?: string;
}
export default async function PropertiesPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const params = await searchParams;
const result = await api.properties.getAll();
if (!result.success) {
return (
<div className="p-8 text-center">
<p className="text-red-600">Error loading data.</p>
</div>
);
}
const filters = {
deal: (params.deal as "sale" | "rent") || undefined,
priceMin: params.minPrice ? parseInt(params.minPrice) : undefined,
priceMax: params.maxPrice ? parseInt(params.maxPrice) : undefined,
search: params.search,
};
return (
<main className="max-w-7xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-8">Our catalog</h1>
<Suspense fallback={<p>Loading...</p>}>
<PropertiesList
properties={result.data.properties}
filters={filters}
locale="it"
/>
</Suspense>
</main>
);
}7. Handling Errors and Edge Cases
APIMO may return properties with incomplete data or missing fields. Add Zod validation to ensure data matches the expected type, and use sensible fallback values in components.
// lib/validation/property.ts
import { z } from "zod";
const PropertySchema = z.object({
id: z.string(),
category: z.number(),
type: z.number(),
price: z.object({
value: z.number().optional(),
currency: z.string().default("EUR"),
}),
pictures: z.array(z.object({ url: z.string() })).default([]),
comments: z.array(z.object({ language: z.string(), title: z.string() })).default([]),
areas: z.array(z.object({ type: z.number(), number: z.number() })).default([]),
regulations: z.array(z.object({ type: z.number(), value: z.string() })).default([]),
city: z.object({ name: z.string() }).optional(),
});
export const parseProperty = (data: unknown) => {
try {
return PropertySchema.parse(data);
} catch (error) {
console.error("Invalid property data:", error);
return null;
}
};Conclusion
Integrating APIMO in Next.js requires three conceptual steps: authenticate with Basic Auth, map numeric IDs to readable labels via constants, and filter on the client side to provide a smooth search experience. Document APIMO ID semantics well in your project, use TypeScript to avoid surprises, and leverage Next.js ISR to keep data fresh without overloading the API. With this structure, your real estate site will be ready to grow alongside your catalog.


