
Integrare l'API APIMO in Next.js: dal dato grezzo al listing immobiliare
APIMO è una piattaforma SaaS per la gestione di agenzie immobiliari che espone una REST API per accedere all'intero catalogo di proprietà. Ho usato questa API per costruire il sito di Scano Immobiliare, un'agenzia che opera tra Italia e Francia. Se la tua agenzia usa APIMO e vuoi esporre i tuoi listing su un sito Next.js, questa guida ti mostra esattamente come farlo: autenticazione, fetch con caching, tipizzazione TypeScript e mapping dei dati grezzi in informazioni leggibili.
1. Autenticazione: Basic Auth con credenziali encoded
APIMO usa HTTP Basic Authentication. Le credenziali sono il PROVIDER_ID e il TOKEN, che vanno combinati in una stringa provider:token, convertiti in Base64, e passati nell'header Authorization. Le credenziali rimangono rigorosamente lato server, dentro le variabili d'ambiente.
// 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. Gli endpoint e il fetch con ISR
APIMO ha due endpoint principali: GET /agencies/{agencyId}/properties per la lista completa e GET /agencies/{agencyId}/properties/{propertyId} per una singola proprietà. Wrappali in un oggetto api che centralizza tutte le chiamate.
// 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 } }
),
},
};Il next: { revalidate: 60 } sfrutta l'ISR di Next.js: la risposta viene cachata e rigenerata ogni 60 secondi, senza chiamare APIMO a ogni page load. Per proprietà che cambiano frequentemente, riduci il valore; per cataloghi stabili, aumentalo.
Il fetchHandler in lib/handlers/fetch.ts aggiunge timeout con AbortController, gestione degli errori e logging. Restituisce sempre un tipo ActionResponse<T> coerente.
// 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. Tipizzare la risposta: da JSON a TypeScript
APIMO restituisce una risposta strutturata con metadati e array di proprietà. La tipizzazione in TypeScript è fondamentale per evitare errori e autocomplete in IDE.
// types/property.d.ts
export interface PropertyResponse {
total_items: number;
timestamp: number;
properties: Property[];
}
export interface Property {
id: string;
category: number; // 1 = vendita, 2 = affitto
type: number; // 1 = appartamento, 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 = classe energetica
value: string;
}4. Il mapping con le costanti
Ogni ID numerico restituito da APIMO è un codice che va tradotto in etichetta leggibile. La semantica degli ID è documenta da APIMO, ma serve una mappa costante per ogni dominio.
// constants/propertyType.ts
export const PROPERTY_TYPES: Record<number, string> = {
1: "Appartamento",
2: "Villa",
3: "Casa",
4: "Studio",
5: "Terreno",
};
// constants/heatingType.ts
export const HEATING_TYPES: Record<number, string> = {
1: "Radiatori",
2: "Pavimento",
3: "Pompa di calore",
4: "Camino",
};
// constants/areasType.ts
export const AREAS_TYPES: Record<number, string> = {
1: "Salotto",
2: "Cucina",
8: "Bagno",
13: "Doccia",
41: "Bagno di servizio",
42: "Spogliatoio",
20: "Camera",
21: "Garage",
22: "Cantina",
};Crea file di utilità in lib/ per estrarre e formattare i dati. Questi helper isolano la logica di mapping e la rendono riutilizzabile in tutta l'app.
// lib/property-utils.ts
import { AREAS_TYPES, HEATING_TYPES } from "@/constants";
import type { Property } from "@/types/property";
export const getEnergyClass = (property: Property) => {
// Regulations con type === 13 contiene la classe energetica (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) => {
// Somma tutte le stanze di tipo bagno/doccia/spogliatoio
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) => {
// Somma camere (type 20)
return property.areas
?.filter((a) => a.type === 20)
.reduce((sum, a) => sum + a.number, 0) || 0;
};
export const getGarageCount = (property: Property) => {
// Somma 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) => {
// Restituisce l'immagine con rank più basso (solitamente la prima)
return property.pictures?.sort((a, b) => a.rank - b.rank)[0] || null;
};5. Filtraggio lato client sui dati aggregati
APIMO non offre paginazione server-side nelle chiamate base, quindi restituisce tutte le proprietà in un'unica risposta. Il filtraggio avviene lato client dopo il fetch. Se il catalogo è molto grande (1000+), considera il server-side filtering o la paginazione.
// 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 || [])];
// Filtra per categoria (vendita/affitto)
if (filters?.deal) {
const category = filters.deal === "sale" ? 1 : 2;
result = result.filter((p) => p.category === category);
}
// Filtra per range di prezzo
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!);
}
// Ricerca full-text su titolo, città, indirizzo
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>
);
};Se il catalogo supera i 500-1000 item, considera di implementare paginazione (con offset/limit custom) o filtraggio server-side: il DOM rimarrà agile e il filtro più veloce.
6. Implementazione in una pagina Next.js
Nel layout di pagina recupera i dati con getAll(), passa il risultato al componente di lista, e usa il parametro searchParams per leggere i filtri dall'URL. L'ISR gestisce il caching automaticamente.
// app/immobili/page.tsx
import { api } from "@/lib/api";
import { PropertiesList } from "@/components/properties-list";
import { Suspense } from "react";
export const metadata = {
title: "Immobili in vendita e affitto",
description: "Scopri il nostro catalogo completo di proprietà",
};
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">Errore nel caricamento dei dati.</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">Il nostro catalogo</h1>
<Suspense fallback={<p>Caricamento...</p>}>
<PropertiesList
properties={result.data.properties}
filters={filters}
locale="it"
/>
</Suspense>
</main>
);
}7. Gestire errori e edge case
APIMO può restituire proprietà con dati incompleti o campi mancanti. Aggiungi validazione Zod per garantire che i dati rispettino il tipo atteso, e usa valore di fallback sensati nei componenti.
// 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;
}
};Conclusione
Integrare APIMO in Next.js richiede tre passaggi concettuali: autenticarti con Basic Auth, mappare gli ID numerici in etichette leggibili tramite costanti, e filtrare lato client per offrire un'esperienza di ricerca fluida. Documenta bene la semantica degli ID APIMO nel tuo progetto, usa TypeScript per evitare sorprese, e sfrutta l'ISR di Next.js per mantenere i dati freschi senza sovraccaricare l'API. Con questa struttura, il tuo sito immobiliare sarà pronto per crescere insieme al catalogo.


