React
This guide builds a project catalogue in a React app created with Vite: a small API client, a useAzizApi hook on useState and useEffect, and three components. No data-fetching library is required; if you already use one, keep the client and swap the hook.
Prerequisites
Section titled “Prerequisites”- React 18 or later, bundled with Vite (the key is read from
import.meta.env). - An agency API key — see Getting started.
- An agency with an approved licence and at least one approved developer contract. See Authentication.
Set up your key
Section titled “Set up your key”-
On estate.azizdubai.com, open API keys and issue a key for the site, for example
Website (browser). Restrict it to the domains that serve the app. -
Add the key to
.env.localin the project root. Vite exposes only variables prefixed withVITE_:.env.local VITE_AZIZ_API_KEY=azk_… -
Make sure
.env.localis listed in.gitignore, and set the same variable in your CI or hosting provider for production builds.
API client
Section titled “API client”The client adds the key, serializes parameters (arrays become CSV, empty values are dropped) and unwraps the response envelope. The hook re-runs the request whenever the path or parameters change and aborts the previous one, so a slow response never overwrites a newer one.
const AZIZ_API_BASE = 'https://agent-dev.azizdubai.com';const AZIZ_API_KEY = import.meta.env.VITE_AZIZ_API_KEY;
export class AzizApiError extends Error { constructor(status, message) { super(message); this.name = 'AzizApiError'; this.status = status; }}
function toQuery(params) { const query = new URLSearchParams(); for (const [name, value] of Object.entries(params)) { if (value === undefined || value === null || value === '') continue; if (Array.isArray(value)) { if (value.length > 0) query.set(name, value.join(',')); } else { query.set(name, String(value)); } } return query.toString();}
export async function azizFetch(path, params = {}, { method = 'GET', body, signal } = {}) { const url = new URL(path, AZIZ_API_BASE); url.search = toQuery(params);
const headers = { 'X-API-Key': AZIZ_API_KEY, Accept: 'application/json' }; if (body !== undefined) headers['Content-Type'] = 'application/json';
const response = await fetch(url, { method, headers, signal, body: body === undefined ? undefined : JSON.stringify(body), });
let payload; try { payload = await response.json(); } catch { throw new AzizApiError(response.status, `unexpected ${response.status} response`); } if (payload.status !== 'success') { throw new AzizApiError(response.status, payload.message ?? 'unknown error'); } return payload.data;}
export function formatPrice(amount, currency = 'AED') { return amount == null ? 'Price on request' : `${currency} ${Math.round(amount).toLocaleString('en')}`;}import { useEffect, useState } from 'react';import { azizFetch } from '../lib/aziz';
export function useAzizApi(path, params = {}) { const [state, setState] = useState({ data: null, error: null, loading: true }); // A stable dependency: a new params object with the same values does not refetch. const query = JSON.stringify(params);
useEffect(() => { const controller = new AbortController(); setState((previous) => ({ ...previous, error: null, loading: true }));
azizFetch(path, JSON.parse(query), { signal: controller.signal }) .then((data) => setState({ data, error: null, loading: false })) .catch((error) => { if (error.name !== 'AbortError') setState({ data: null, error, loading: false }); });
return () => controller.abort(); }, [path, query]);
return state;}While a new page loads, the hook keeps the previous data, so lists do not flash empty during pagination.
Recipes
Section titled “Recipes”Paginated project list with filters
Section titled “Paginated project list with filters”Filters are applied on submit, not on every keystroke. price_min and price_max are in the key’s prices; bedrooms is a CSV where 0 is a studio and 5 means 5+. See Filtering projects and Pagination.
import { useState } from 'react';import { useAzizApi } from '../hooks/useAzizApi';import { formatPrice } from '../lib/aziz';
const PAGE_SIZE = 12;const BEDROOMS = [ ['0', 'Studio'], ['1', '1'], ['2', '2'], ['3', '3'], ['4', '4'], ['5', '5+'],];
export function ProjectList() { const [filters, setFilters] = useState({ price_min: '', price_max: '', bedrooms: [] }); const [offset, setOffset] = useState(0); const { data, error, loading } = useAzizApi('/projects', { ...filters, limit: PAGE_SIZE, offset });
function handleSubmit(event) { event.preventDefault(); const form = new FormData(event.currentTarget); setFilters({ price_min: form.get('price_min'), price_max: form.get('price_max'), bedrooms: form.getAll('bedrooms'), }); setOffset(0); }
const total = data?.total ?? 0; const items = data?.items ?? [];
return ( <section> <form onSubmit={handleSubmit}> <input name="price_min" type="number" min="0" step="1000" placeholder="Min price" /> <input name="price_max" type="number" min="0" step="1000" placeholder="Max price" /> <fieldset> <legend>Bedrooms</legend> {BEDROOMS.map(([value, label]) => ( <label key={value}> <input type="checkbox" name="bedrooms" value={value} /> {label} </label> ))} </fieldset> <button type="submit">Search</button> </form>
{error && <p role="alert">Could not load projects: {error.message}</p>} {!error && !loading && total === 0 && <p>No projects match these filters.</p>}
<div className="project-grid" aria-busy={loading}> {items.map((project) => ( <a key={project.id} className="project-card" href={`/projects/${project.id}`}> {project.logo && <img src={project.logo} alt="" loading="lazy" />} <h3>{project.name}</h3> <p>{project.company?.name}</p> <p>From {formatPrice(project.price_from, project.currency)}</p> </a> ))} </div>
{total > 0 && ( <nav> <button disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}> Previous </button> <span> {offset + 1}–{offset + items.length} of {total} </span> <button disabled={offset + items.length >= total} onClick={() => setOffset(offset + PAGE_SIZE)}> Next </button> </nav> )} </section> );}Project details page
Section titled “Project details page”GET /projects/{id} returns location, cost, payment plan, media and units in one response. Pass the ID from your router — for example, useParams() in React Router. A draft, deleted or unknown project answers 404.
import { useAzizApi } from '../hooks/useAzizApi';import { formatPrice } from '../lib/aziz';import { ApplicationForm } from './ApplicationForm';
export function ProjectPage({ id }) { const { data: project, error, loading } = useAzizApi(`/projects/${id}`);
if (error?.status === 404) return <p>This project is no longer available.</p>; if (error) return <p role="alert">Could not load the project: {error.message}</p>; if (loading || !project) return <p>Loading…</p>;
const currency = project.cost?.currency ?? 'AED'; const images = (project.media?.images ?? []).filter((image) => image.file);
return ( <article> <h1>{project.name}</h1> <p>{[project.area_name, project.area_city].filter(Boolean).join(', ')}</p> <div className="gallery"> {images.map((image) => ( <img key={image.id} src={image.file.url} alt="" loading="lazy" /> ))} </div> <p>{project.description}</p>
<table> <thead> <tr> <th>Unit</th> <th>Bedrooms</th> <th>Area, m²</th> <th>Price</th> </tr> </thead> <tbody> {project.units.map((unit) => ( <tr key={unit.id}> <td>{unit.unit_number}</td> <td>{unit.bedrooms === 0 ? 'Studio' : unit.bedrooms}</td> <td>{unit.unit_area}</td> <td>{formatPrice(unit.price, currency)}</td> </tr> ))} </tbody> </table>
{project.units.length > 0 && <ApplicationForm units={project.units} />} </article> );}Unit application form
Section titled “Unit application form”POST /units/{id}/applications requires client.first_name and at least one of client.email or client.mobile_number. A 400 carries the validation message — show it next to the form. See Submitting applications for the full body.
import { useState } from 'react';import { AzizApiError, azizFetch } from '../lib/aziz';
const orNull = (value) => (value === null || value.trim() === '' ? null : value.trim());
export function ApplicationForm({ units }) { const [status, setStatus] = useState('idle'); // idle | sending | sent const [message, setMessage] = useState('');
async function handleSubmit(event) { event.preventDefault(); const form = event.currentTarget; const fields = new FormData(form); const client = { first_name: orNull(fields.get('first_name')), last_name: orNull(fields.get('last_name')), email: orNull(fields.get('email')), mobile_number: orNull(fields.get('mobile_number')), }; if (!client.email && !client.mobile_number) { setMessage('Please enter an email address or a mobile number.'); return; }
setStatus('sending'); setMessage(''); try { await azizFetch(`/units/${fields.get('unit_id')}/applications`, {}, { method: 'POST', body: { client, preferences: { based_on_unit: true }, message: orNull(fields.get('message')), currency: orNull(fields.get('currency')), }, }); form.reset(); setStatus('sent'); } catch (error) { setStatus('idle'); if (error instanceof AzizApiError && error.status === 400) { setMessage(`Please check the form: ${error.message}`); } else if (error instanceof AzizApiError && error.status === 404) { setMessage('This unit is no longer available.'); } else { setMessage('Something went wrong. Please try again later.'); console.error(error); } } }
if (status === 'sent') return <p role="status">Thank you! An agent will contact you shortly.</p>;
return ( <form onSubmit={handleSubmit}> <select name="unit_id" required> {units.map((unit) => ( <option key={unit.id} value={unit.id}> Unit {unit.unit_number} </option> ))} </select> <input name="first_name" required maxLength={255} placeholder="First name" /> <input name="last_name" maxLength={255} placeholder="Last name" /> <input name="email" type="email" maxLength={255} placeholder="Email" /> <input name="mobile_number" type="tel" maxLength={50} placeholder="+971 50 000 00 00" /> <select name="currency" defaultValue=""> <option value="">Project currency</option> <option value="AED">AED</option> <option value="USD">USD</option> </select> <textarea name="message" maxLength={5000} placeholder="Message" /> {/* Disabled while sending: applications are not idempotent. */} <button type="submit" disabled={status === 'sending'}> {status === 'sending' ? 'Sending…' : 'Send enquiry'} </button> {message && <p role="alert">{message}</p>} </form> );}Production checklist
Section titled “Production checklist”- Handle
401and403as configuration errors.missing api key,invalid api keyandorigin is not allowed for this api keywill not fix themselves on retry — log them and show a generic message. See Troubleshooting. - Never commit the key. Keep it in
.env.local(gitignored) and in your CI or hosting environment settings. - Cache
GETresponses. Keep list and project responses in a module-levelMaporsessionStoragefor a few minutes, or let a server proxy cache them. Never cachePOSTrequests. - Use separate keys for browser and server. The browser key carries a domain list; a key used by a proxy or backend must have no domain list, because server requests carry no
OriginorRefererand are rejected by a restricted key. - Use a separate key for local development. A key restricted to your production domains rejects requests from
localhost.