Managing rental properties means tracking buildings, individual units, lease status, and tenant information across what quickly becomes a tangled data model. Spreadsheets break down once you have more than a handful of properties, and most off-the-shelf tools don't let you structure the data the way your business actually thinks about it.
A headless CMS solves this by letting you define your own data model and exposing it through a ready-made API. This tutorial builds a property management portal with Strapi 5 on the backend and a React + Vite frontend. You'll model properties and units as related Collection Types, create a reusable address component, add a custom route for querying available units, and consume the REST API from a React application that displays property listings with images, details, and unit availability.
If you've worked with a real estate directory before, this tutorial extends that pattern with unit-level granularity and lease-status tracking.
In brief:
- Model properties and units as related Strapi 5 Collection Types with a reusable address component
- Create a custom route and controller action for querying available units using the Document Service API
- Configure public read permissions and API tokens for secure write access
- Build a React frontend with Vite that consumes the Strapi REST API with pagination, population, and image handling
Prerequisites
Before starting, confirm you have:
- Node.js v24.x LTS (v24.16.0 or later). Verify with
node -v. - npm v11 (ships with Node.js 24). Verify with
npm -v. - Strapi v5.47.0+ (installed during project setup)
- A code editor (VS Code, WebStorm, or similar)
- A terminal for running CLI commands
- Basic familiarity with JavaScript, React, and REST APIs
No external accounts or API keys are needed. The project uses SQLite (bundled) for the database and the local file upload provider.
Setting up the Strapi Backend
Step 1: Create a New Strapi Project
Open your terminal and scaffold a fresh Strapi 5 project:
npx create-strapi@latest property-portal-backend --skip-cloudWhen prompted, select SQLite as your database and accept the defaults. After installation:
cd property-portal-backend
npm run build
npm run developYour browser opens http://localhost:1337/admin. Create your first admin account through the Admin Panel registration form.
Step 2: Create the Address Component
Stop the dev server (Ctrl+C). Strapi components are reusable field groups that live inside a category subfolder under src/components. Create the address component so both the Property and any future Content-Types can share the same address structure.
mkdir -p src/components/shared// src/components/shared/address.json
{
"collectionName": "components_shared_addresses",
"info": {
"displayName": "Address"
},
"attributes": {
"street": {
"type": "string",
"required": true
},
"city": {
"type": "string",
"required": true
},
"state": {
"type": "string"
},
"zip": {
"type": "string"
},
"country": {
"type": "string",
"default": "US"
}
}
}The collectionName follows the pattern components_[category]_[name]. The component category (shared) matches the subfolder name.
Step 3: Define the Property Content-Type
The Property Collection Type represents a building or property. It holds the address, a name, a description, images through the Media Library, and a one-to-many relation to units. Draft and Publish is enabled so you can prepare property listings before making them public. For a deeper look at content modeling patterns, that guide covers the principles.
Create the directory structure:
mkdir -p src/api/property/content-types/property
mkdir -p src/api/property/controllers
mkdir -p src/api/property/services
mkdir -p src/api/property/routes// src/api/property/content-types/property/schema.json
{
"kind": "collectionType",
"collectionName": "properties",
"info": {
"singularName": "property",
"pluralName": "properties",
"displayName": "Property"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "name",
"required": true
},
"description": {
"type": "richtext"
},
"propertyType": {
"type": "enumeration",
"enum": ["apartment", "house", "condo", "commercial"],
"default": "apartment"
},
"address": {
"type": "component",
"repeatable": false,
"component": "shared.address"
},
"images": {
"type": "media",
"multiple": true,
"allowedTypes": ["images"]
},
"units": {
"type": "relation",
"relation": "oneToMany",
"target": "api::unit.unit",
"mappedBy": "property"
}
}
}The units field uses mappedBy because this is the inverse side of the relation. The Unit Content-Type (defined next) owns the relationship with inversedBy. The images field accepts multiple image uploads, which Strapi stores in the Media Library.
Add the core controller, service, and route files:
// src/api/property/controllers/property.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::property.property');// src/api/property/services/property.js
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::property.property');// src/api/property/routes/property.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::property.property');Step 4: Define the Unit Content-Type
Each unit represents an individual rental space within a property: an apartment number, its bedroom/bathroom count, rent amount, and availability status.
mkdir -p src/api/unit/content-types/unit
mkdir -p src/api/unit/controllers
mkdir -p src/api/unit/services
mkdir -p src/api/unit/routes// src/api/unit/content-types/unit/schema.json
{
"kind": "collectionType",
"collectionName": "units",
"info": {
"singularName": "unit",
"pluralName": "units",
"displayName": "Unit"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"unitNumber": {
"type": "string",
"required": true
},
"bedrooms": {
"type": "integer",
"required": true
},
"bathrooms": {
"type": "integer"
},
"squareFeet": {
"type": "integer"
},
"rentAmount": {
"type": "decimal",
"required": true
},
"availabilityStatus": {
"type": "enumeration",
"enum": ["available", "occupied", "maintenance"],
"default": "available",
"required": true
},
"availableDate": {
"type": "date"
},
"property": {
"type": "relation",
"relation": "manyToOne",
"target": "api::property.property",
"inversedBy": "units"
}
}
}The property field with manyToOne and inversedBy makes Unit the owning side of the relation. The field is named availabilityStatus (not status) because status is a reserved attribute name in Strapi 5.
// src/api/unit/controllers/unit.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::unit.unit');// src/api/unit/services/unit.js
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::unit.unit');// src/api/unit/routes/unit.js
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::unit.unit');Step 5: Add a Custom Route for Available Units
Beyond standard CRUD endpoints, the portal needs a dedicated route that returns only available units, optionally filtered by property. Create a custom route file prefixed with 01- so it loads before the core router:
// src/api/unit/routes/01-custom-unit.js
module.exports = {
routes: [
{
method: 'GET',
path: '/units/available',
handler: 'api::unit.unit.findAvailable',
config: {
policies: [],
middlewares: [],
},
},
],
};Update the controller to add the findAvailable action. This uses the Document Service API to query units with the available status and optionally filter by a property documentId:
// src/api/unit/controllers/unit.js
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::unit.unit', ({ strapi }) => ({
async findAvailable(ctx) {
const { propertyId } = ctx.query;
const filters = {
availabilityStatus: { $eq: 'available' },
};
if (propertyId) {
filters.property = { documentId: { $eq: propertyId } };
}
const units = await strapi.documents('api::unit.unit').findMany({
filters,
populate: {
property: {
fields: ['name', 'slug'],
},
},
sort: 'rentAmount:asc',
});
ctx.body = { data: units };
},
}));The strapi.documents() call uses the Document Service API, which is the Strapi 5 replacement for the deprecated Entity Service. The documentId is a 24-character alphanumeric string, not the numeric id from Strapi v4.
Step 6: Restart Strapi and Configure Permissions
Restart the server to register the new Content-Types:
npm run build
npm run developIn the Admin Panel, navigate to Settings > Users and Permissions plugin > Roles > Public and enable these actions:
| Content-Type | Allowed Actions |
|---|---|
| Property | find, findOne |
| Unit | find, findOne, findAvailable |
Click Save. This lets the frontend read property and unit data without authentication. Write operations (create, update, delete) remain restricted to authenticated requests. For more on how API authorization works with Strapi's role system, that guide covers the mechanics.
Step 7: Create an API Token
If your frontend needs write access (creating properties or units), generate an API token:
- Navigate to Settings > Global settings > API Tokens
- Click Create new API Token
- Set Name to "Property Portal Frontend"
- Set Token type to "Full access"
- Set Token duration to "Unlimited" (for development)
- Click Save and copy the token immediately
The token displays only once unless you configure a transfer token salt. For broader guidance on API security, that checklist covers additional hardening steps.
Step 8: Add Sample Data
Use the Content Manager in the Admin Panel to create two or three properties with addresses and images. For each property, add a few units with varying availability statuses and rent amounts. Publish each property so the REST API returns it (the REST API defaults to status=published).
Verify the data is accessible:
curl "http://localhost:1337/api/properties?populate=address&populate=images&populate=units" | python3 -m json.toolThe response uses Strapi 5's flat format. Fields sit directly on each object, with no data.attributes wrapper:
{
"data": [
{
"id": 1,
"documentId": "abc123def456",
"name": "Riverside Apartments",
"slug": "riverside-apartments",
"propertyType": "apartment",
"address": {
"street": "100 River Road",
"city": "Portland",
"state": "OR",
"zip": "97201",
"country": "US"
},
"units": [
{
"id": 1,
"documentId": "unit001abc",
"unitNumber": "101",
"bedrooms": 2,
"bathrooms": 1,
"rentAmount": 1450.00,
"availabilityStatus": "available"
}
]
}
],
"meta": {
"pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 2 }
}
}Test the custom available units endpoint:
curl "http://localhost:1337/api/units/available" | python3 -m json.toolBuilding the React Frontend
The frontend is a React application scaffolded with Vite. It fetches property and unit data from the Strapi REST API and renders property cards, detail views, and unit availability. For a broader introduction to building a CRUD app with React and a headless CMS, that guide covers the fundamentals.
Step 1: Scaffold the Vite and React Project
Open a new terminal (keep Strapi running) and scaffold the frontend:
npm create vite@latest property-portal-frontend -- --template react
cd property-portal-frontend
npm installCreate a .env file in the frontend project root:
# .env
VITE_STRAPI_URL=http://localhost:1337
VITE_STRAPI_TOKEN=your-api-token-from-step-7Vite only exposes variables prefixed with VITE_ to the browser bundle.
Step 2: Create the Strapi Client Module
This module centralizes all API calls. Strapi 5 requires explicit populate and filtering parameters since relations, components, and media are not returned by default.
// src/lib/strapiClient.js
const BASE_URL = import.meta.env.VITE_STRAPI_URL || 'http://localhost:1337';
const API_TOKEN = import.meta.env.VITE_STRAPI_TOKEN;
const headers = API_TOKEN
? { Authorization: `Bearer ${API_TOKEN}` }
: {};
export async function fetchProperties(page = 1, pageSize = 10) {
const params = new URLSearchParams({
'pagination[page]': String(page),
'pagination[pageSize]': String(pageSize),
'populate[0]': 'address',
'populate[1]': 'images',
'populate[2]': 'units',
'sort': 'name:asc',
});
const response = await fetch(`${BASE_URL}/api/properties?${params}`, { headers });
if (!response.ok) {
throw new Error(`Failed to fetch properties: ${response.status}`);
}
return response.json();
}
export async function fetchProperty(documentId) {
const params = new URLSearchParams({
'populate[0]': 'address',
'populate[1]': 'images',
'populate[units][populate][0]': 'property',
});
const response = await fetch(
`${BASE_URL}/api/properties/${documentId}?${params}`,
{ headers }
);
if (!response.ok) {
throw new Error(`Failed to fetch property: ${response.status}`);
}
return response.json();
}
export async function fetchAvailableUnits(propertyDocumentId) {
const params = propertyDocumentId
? `?propertyId=${propertyDocumentId}`
: '';
const response = await fetch(
`${BASE_URL}/api/units/available${params}`,
{ headers }
);
if (!response.ok) {
throw new Error(`Failed to fetch available units: ${response.status}`);
}
return response.json();
}
export function imageUrl(media) {
if (!media || !media.url) return null;
return `${BASE_URL}${media.url}`;
}Step 3: Build the Property Image Component
This component handles Strapi media URLs and provides a fallback placeholder when no image is available:
// src/components/PropertyImage.jsx
import { imageUrl } from '../lib/strapiClient';
export default function PropertyImage({ media, alt, className }) {
const src = imageUrl(media);
if (!src) {
return (
<div
className={className}
style={{
backgroundColor: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontSize: '0.9rem',
minHeight: '200px',
}}
>
No image
</div>
);
}
return (
<img
src={src}
alt={alt || media?.alternativeText || 'Property image'}
className={className}
style={{ width: '100%', height: '200px', objectFit: 'cover', borderRadius: '6px' }}
loading="lazy"
/>
);
}Step 4: Build the Property Card Component
The card displays a property summary with its first image, address, unit count, and a rent range:
// src/components/PropertyCard.jsx
import PropertyImage from './PropertyImage';
export default function PropertyCard({ property, onSelect }) {
const firstImage = property.images && property.images.length > 0
? property.images[0]
: null;
const address = property.address
? `${property.address.street}, ${property.address.city}, ${property.address.state} ${property.address.zip}`
: 'No address';
const unitCount = property.units ? property.units.length : 0;
const availableUnits = property.units
? property.units.filter((u) => u.availabilityStatus === 'available')
: [];
const rentRange = availableUnits.length > 0
? (() => {
const rents = availableUnits.map((u) => u.rentAmount);
const min = Math.min(...rents);
const max = Math.max(...rents);
return min === max ? `$${min}/mo` : `$${min} – $${max}/mo`;
})()
: 'No units available';
return (
<article
onClick={() => onSelect(property.documentId)}
style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden',
cursor: 'pointer',
transition: 'box-shadow 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)')}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
>
<PropertyImage media={firstImage} alt={property.name} />
<div style={{ padding: '16px' }}>
<h3 style={{ margin: '0 0 4px' }}>{property.name}</h3>
<p style={{ color: '#666', fontSize: '0.9rem', margin: '0 0 8px' }}>{address}</p>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.85rem' }}>
<span>{unitCount} unit{unitCount !== 1 ? 's' : ''} total</span>
<span style={{ color: availableUnits.length > 0 ? '#059669' : '#999' }}>
{availableUnits.length > 0
? `${availableUnits.length} available`
: 'Fully occupied'}
</span>
</div>
<p style={{ fontWeight: 600, marginTop: '8px', color: '#4945ff' }}>{rentRange}</p>
</div>
</article>
);
}Step 5: Build the Property Detail Component
This component fetches a single property by documentId and displays its full details, images, and unit list:
// src/components/PropertyDetail.jsx
import { useState, useEffect } from 'react';
import { fetchProperty } from '../lib/strapiClient';
import PropertyImage from './PropertyImage';
export default function PropertyDetail({ documentId, onBack }) {
const [property, setProperty] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
setError(null);
fetchProperty(documentId)
.then((json) => {
setProperty(json.data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, [documentId]);
if (loading) return <p>Loading property details...</p>;
if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
if (!property) return <p>Property not found.</p>;
const address = property.address
? `${property.address.street}, ${property.address.city}, ${property.address.state} ${property.address.zip}`
: 'No address';
return (
<div>
<button
onClick={onBack}
style={{
padding: '8px 16px',
border: '1px solid #ccc',
borderRadius: '6px',
background: '#fff',
cursor: 'pointer',
marginBottom: '16px',
}}
>
← Back to listings
</button>
<h2>{property.name}</h2>
<p style={{ color: '#666' }}>{address}</p>
<span
style={{
display: 'inline-block',
background: '#e0e7ff',
color: '#3730a3',
padding: '2px 10px',
borderRadius: '4px',
fontSize: '0.85rem',
marginTop: '4px',
}}
>
{property.propertyType}
</span>
{property.description && (
<div
style={{ marginTop: '16px', lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: property.description }}
/>
)}
{property.images && property.images.length > 0 && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '12px',
marginTop: '20px',
}}
>
{property.images.map((img) => (
<PropertyImage key={img.id} media={img} alt={property.name} />
))}
</div>
)}
<h3 style={{ marginTop: '24px' }}>Units</h3>
{property.units && property.units.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '8px' }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Unit</th>
<th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Bed/Bath</th>
<th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Sq Ft</th>
<th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Rent</th>
<th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Status</th>
</tr>
</thead>
<tbody>
{property.units.map((unit) => (
<tr key={unit.documentId}>
<td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>{unit.unitNumber}</td>
<td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>
{unit.bedrooms}bd / {unit.bathrooms || 0}ba
</td>
<td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>
{unit.squareFeet ? `${unit.squareFeet}` : '—'}
</td>
<td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>${unit.rentAmount}/mo</td>
<td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>
<span
style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '0.85em',
backgroundColor:
unit.availabilityStatus === 'available'
? '#d1fae5'
: unit.availabilityStatus === 'occupied'
? '#fee2e2'
: '#fef3c7',
color:
unit.availabilityStatus === 'available'
? '#065f46'
: unit.availabilityStatus === 'occupied'
? '#991b1b'
: '#92400e',
}}
>
{unit.availabilityStatus}
</span>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p>No units listed for this property.</p>
)}
</div>
);
}Step 6: Assemble the App Component
// src/App.jsx
import { useState, useEffect, useCallback } from 'react';
import { fetchProperties } from './lib/strapiClient';
import PropertyCard from './components/PropertyCard';
import PropertyDetail from './components/PropertyDetail';
export default function App() {
const [properties, setProperties] = useState([]);
const [pagination, setPagination] = useState(null);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedId, setSelectedId] = useState(null);
const loadProperties = useCallback(async (p) => {
setLoading(true);
setError(null);
try {
const result = await fetchProperties(p, 6);
setProperties(result.data);
setPagination(result.meta.pagination);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadProperties(page);
}, [page, loadProperties]);
if (selectedId) {
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
<PropertyDetail
documentId={selectedId}
onBack={() => setSelectedId(null)}
/>
</div>
);
}
return (
<div style={{ maxWidth: '960px', margin: '0 auto', padding: '20px', fontFamily: 'system-ui, sans-serif' }}>
<h1>Property Listings</h1>
{loading && <p>Loading properties...</p>}
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{!loading && !error && (
<>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '20px',
marginTop: '16px',
}}
>
{properties.map((prop) => (
<PropertyCard
key={prop.documentId}
property={prop}
onSelect={setSelectedId}
/>
))}
</div>
{pagination && (
<div style={{ marginTop: '24px', display: 'flex', gap: '10px', alignItems: 'center' }}>
<button
onClick={() => setPage((p) => p - 1)}
disabled={page <= 1}
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: '6px', cursor: page <= 1 ? 'default' : 'pointer', opacity: page <= 1 ? 0.4 : 1 }}
>
Previous
</button>
<span>
Page {pagination.page} of {pagination.pageCount} ({pagination.total} properties)
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= pagination.pageCount}
style={{ padding: '8px 16px', border: '1px solid #ccc', borderRadius: '6px', cursor: page >= pagination.pageCount ? 'default' : 'pointer', opacity: page >= pagination.pageCount ? 0.4 : 1 }}
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}Update main.jsx to remove any default Vite boilerplate:
// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);Step 7: Configure CORS
Update the Strapi middleware configuration to allow requests from the Vite dev server:
// config/middlewares.ts
export default [
'strapi::logger',
'strapi::errors',
'strapi::security',
{
name: 'strapi::cors',
config: {
origin: ['http://localhost:5173', 'http://localhost:1337'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
headers: ['Content-Type', 'Authorization'],
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];Restart Strapi after saving the CORS configuration.
Putting It All Together
Start Both Servers
Terminal 1 (Strapi backend):
cd property-portal-backend
npm run build
npm run developStrapi starts on http://localhost:1337.
Terminal 2 (React frontend):
cd property-portal-frontend
npm run devVite starts on http://localhost:5173.
Verify the Setup
Open http://localhost:5173 in your browser. You should see:
- A grid of property cards showing each property's first image, name, address, unit count, and rent range
- Clicking a card opens the detail view with all images, the full address, the property type tag, and a table of units with availability status badges
- Pagination controls at the bottom of the listing page
- The "Back to listings" button returns to the grid view
Test the custom available units endpoint independently:
curl "http://localhost:1337/api/units/available" | python3 -m json.toolNext Steps
- Deploy to production. Host the Strapi backend on Strapi Cloud or a self-hosted provider, and deploy the React frontend to Vercel. Switch from SQLite to PostgreSQL for production. The deployment guide covers hosting options.
- Add tenant and lease Collection Types. Extend the data model with Tenant and Lease Content-Types linked to units. Track lease start/end dates, monthly rent, and security deposits. Use Strapi's REST API filters to query expiring leases.
- Enable Draft and Publish workflows. With Draft and Publish already on for properties, you can build a review flow where new listings go through an approval stage before going live.
- Generate TypeScript types. Run
npx ts:generate-typesto produce TypeScript definitions from your Strapi schemas. This gives the frontend type safety when consuming the API. For the TypeScript benefits of this approach, that guide covers the advantages. - Add search and advanced filtering. Use Strapi 5's filter operators (
$containsi,$gte,$lte,$between) to let users search by city, filter by rent range, or find properties with a minimum bedroom count. Theqslibrary can generate complex query strings from JavaScript objects. - Integrate maps. Add latitude and longitude fields to the address component, then render property locations on a map in the frontend. For REST API design patterns around geospatial queries, that guide covers the principles.




