Build a Live Subdivision Lot Map
Build a live lot map with FastAPI, Supabase, and MapLibre.
Introduction
30 Second Summary
When lot availability changes, buyers can end up reading an outdated listing or chasing the latest spreadsheet. A simple question about which homes are still available becomes surprisingly hard to answer.
In this project, you will build a browser-based subdivision map that combines live lot availability from Supabase with parcel shapes from GeoJSON. Visitors can recognize each lot's status by color before clicking any of the six parcels to inspect its details.
What You'll Build
The finished map lets a visitor scan every parcel, pick a lot, and see its live status plus home details without leaving the page.
By the end of this project, you'll have:
- An interactive lot map powered by MapLibre GL JS. You'll see six parcel outlines over a basemap with live colors for Available, Reserved, and Sold.
- An online inventory that staff can update through the browser. Your map reads the same six records through a FastAPI JSON endpoint.
- Click-to-inspect lot details showing the address, price, bedroom count, bathroom count, and status. Visitors can read the current inventory without receiving database write access.
- Secret Mission: Add a status control that shows all lots or filters the map to Available, Reserved, or Sold parcels.
Are there any prerequisites?
You'll need a Windows computer with Visual Studio Code already installed. Python setup is covered in the guide. Internet access and an email address are required for the free Supabase account.
Before We Start
Before the hands-on work, lock in the public Cedar View lot-availability map you are building. Its design pairs staff-managed inventory in Supabase with six parcel shapes from local GeoJSON.
Prepare VS Code and Python
The Cedar View map needs a Python process to serve its future backend. Visual Studio Code gives you the workspace where that process will run.
This step pairs Python 3.14.8 with the Microsoft Python extension. You will isolate the map's packages inside a virtual environment.
In this step, get ready to:
- Prepare Python 3.14.8 with the Microsoft Python extension.
- Create an isolated .venv workspace in VS Code.
- Install the pinned dependencies from requirements.txt.
Install Python and the Microsoft extension
VS Code edits your project files. The Python interpreter executes the application code.
The Microsoft extension lets VS Code discover Python interpreters. It also connects your workspace to the selected environment.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open VS Code.
- Select Terminal from the top menu.
- Select New Terminal.
- Select PowerShell from the terminal shell menu if another shell is active.
- Check the installed Python version by running this command:
py -3 --version
Compare the reported version with the required Python 3.14.8 release.
✔️ I see the required Python version
Python 3.14.8 is ready for this project.
ⓧ I see an older Python version
Your existing interpreter can stay installed. Add Python 3.14.8 for this workspace.
- Open the official Python downloads page for Windows.
- Download the Windows installer for Python 3.14.8.
- Run the downloaded installer.
- Complete the installer prompts using the default options.
- Close the existing PowerShell terminal after installation.
- Create a fresh PowerShell terminal from the VS Code Terminal menu.
- Recheck the Python version by running:
py -3 --version
You should now see Python 3.14.8 in the output.
ⓧ Python is not found
Windows needs a Python interpreter before VS Code can run the map's backend.
- Open the official Python downloads page for Windows.
- Download the Windows installer for Python 3.14.8.
- Run the downloaded installer.
- Complete the installer prompts using the default options.
- Close the existing PowerShell terminal after installation.
- Create a fresh PowerShell terminal from the VS Code Terminal menu.
- Confirm the installation by running:
py -3 --version
You should now see Python 3.14.8 in the output.
- Press Ctrl+Shift+X to open the Extensions view.
- Enter ms-python.python in the extension search field.
- Select the Python extension published by Microsoft.
- Click Install on the extension page.
Good progress. VS Code now has the language support it needs to work with your Python interpreter.
Extension installation stuck?
- Confirm your internet connection is active.
- Confirm the selected extension is published by Microsoft.
- Restart VS Code if the extension remains in a loading state.
Ask for help with the exact state you see: Help me install the Microsoft Python extension in VS Code on Windows.
Create the isolated VS Code workspace
An opened folder gives the VS Code terminal a consistent project location. The .venv folder keeps this map's Python packages inside that location.
VS Code may show a Workspace Trust prompt after the folder opens. You created this empty folder yourself, so it is safe to trust.
- Click File in the top menu.
- Select Open Folder.
- Navigate to your Desktop in the folder dialog.
- Create a folder named subdivision-lot-map.
- Choose the new subdivision-lot-map folder to open it as your workspace.
- Choose the trust option if VS Code asks about Workspace Trust.
- Select Terminal from the top menu.
- Select New Terminal.
The new PowerShell terminal starts inside the subdivision-lot-map folder.
- Create the virtual environment by running this command:
python -m venv .venv
You should see a .venv folder appear under subdivision-lot-map in the Explorer sidebar.
What is a virtual environment?
A virtual environment stores project-specific Python packages inside .venv. This keeps the map's package versions separate from other Python projects.
Virtual environment missing?
- Confirm the Explorer sidebar shows subdivision-lot-map as the opened folder.
- Create a fresh PowerShell terminal if Python was installed while VS Code was already open.
Ask for help with the command output: Why did my .venv folder fail to appear?
- Activate the virtual environment by running this command:
.venv\Scripts\Activate.ps1
Your PowerShell prompt should now include .venv before the folder path.
PowerShell blocked activation?
PowerShell can block local activation scripts through its execution policy. The fallback below changes the policy for your Windows account.
- Allow local activation scripts for your account by running:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
The CurrentUser scope limits this policy change to your Windows account.
- Rerun the activation command from above.
Ask for help if activation is still blocked: Help me activate my Windows virtual environment.
- Press Ctrl+Shift+P to open the Command Palette.
- Enter Python: Select Interpreter.
- Select the interpreter entry for .venv.
The VS Code status bar now identifies .venv as the selected Python environment. Your terminal prompt also shows that the same environment is active.
Install the pinned project dependencies
Pinned requirements make the Python environment reproducible. Two companion files prepare the workspace for future Supabase settings without storing live credentials.
- Right-click the subdivision-lot-map folder in the Explorer sidebar.
- Select New File.
- Enter requirements.txt as the file name.
- Add the pinned dependencies by replacing the empty file with:
fastapi==0.143.0
uvicorn==0.54.0
httpx==0.28.1
python-dotenv==1.2.4
What will these packages do?
- FastAPI 0.143.0 provides the web application framework.
- Uvicorn 0.54.0 runs the FastAPI application during local development.
- HTTPX 0.28.1 sends requests to the hosted inventory API.
- python-dotenv 1.2.4 loads future settings from a local environment file.
- Press Ctrl+S to save requirements.txt.
- Confirm the Explorer sidebar lists requirements.txt inside subdivision-lot-map.
Can't see requirements.txt?
- Confirm the file is directly inside subdivision-lot-map.
- Confirm the file name ends with one .txt extension.
Ask for help checking the file location: Help me find my requirements file.
- Right-click the subdivision-lot-map folder in the Explorer sidebar.
- Select New File.
- Enter .env.example as the file name.
- Add the future Supabase setting placeholders by replacing the empty file with:
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_PUBLISHABLE_KEY=sb_publishable_your-key-here
What does this template do?
The .env.example file records the setting names that the backend will need. Its values are placeholders, so this file contains no live credentials.
- Press Ctrl+S to save .env.example.
- Confirm the Explorer sidebar lists .env.example inside subdivision-lot-map.
Template file missing?
- Confirm the file name starts with a full stop.
- Confirm the file name ends with .example.
Ask for help checking the exact name: Help me create .env.example correctly.
- Right-click the subdivision-lot-map folder in the Explorer sidebar.
- Select New File.
- Enter .gitignore as the file name.
- Add the local-only paths by replacing the empty file with:
.venv/
.env
__pycache__/
Why ignore these paths?
- The .venv/ entry keeps installed packages out of source control.
- The .env entry protects the future local settings file.
- The __pycache__/ entry excludes generated Python cache files.
- Press Ctrl+S to save .gitignore.
- Confirm the Explorer sidebar lists .gitignore inside subdivision-lot-map.
Ignore file missing?
- Confirm the file name starts with a full stop.
- Remove any extra .txt extension from the file name.
Ask for help checking the exact file: Help me fix my .gitignore file.
✔️ Awesome, I've got everything!
Great. Double-check that all three files are saved inside subdivision-lot-map.
ⓧ I'd like to double check the full code
Compare each saved file with its complete contents below.
fastapi==0.143.0
uvicorn==0.54.0
httpx==0.28.1
python-dotenv==1.2.4
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_PUBLISHABLE_KEY=sb_publishable_your-key-here
.venv/
.env
__pycache__/
- Switch back to the activated PowerShell terminal from your subdivision-lot-map workspace.
- Install the pinned dependencies by running:
python -m pip install -r requirements.txt
Solid progress. The map now has its exact Python package set inside .venv.
Dependency installation failed?
- Confirm the PowerShell prompt includes .venv.
- Confirm requirements.txt is saved inside subdivision-lot-map.
- Check that your internet connection is active.
Ask for help with the installation output: Help me fix my Python dependency installation.
Before you run the final check, do you expect every package import to succeed inside .venv?
- Verify all four dependency imports by running this command:
python -c "import fastapi, httpx, dotenv, uvicorn; print('Dependencies ready')"
You should see Dependencies ready in the PowerShell terminal. That output confirms the selected virtual environment can import every required package.
Missing the confirmation message?
- Confirm the terminal prompt includes .venv.
- Confirm the dependency installation completed without an error.
- Rerun the final import check after correcting the failed installation.
Ask for help with the import that failed: Help me debug my dependency import check.
Your isolated Python workspace is ready to run the Cedar View backend. Next, you will create the online lot inventory in Supabase.
Create the Supabase Inventory
Your Python workspace is ready to run the application. The inventory still needs a shared home that staff can update outside your computer.
Supabase gives the project a hosted PostgreSQL database. Its Table Editor lets staff update inventory in the browser.
You'll seed six lots in this step. Public visitors will receive read-only data.
In this step, get ready to:
- Create the hosted lots table with six inventory rows.
- Protect the table with Row Level Security.
- Store the project connection values in .env.
Create the hosted inventory
The hosted table becomes the inventory source of truth. Each row represents one lot that the map can identify through its lot_id value.
- Visit the Supabase dashboard in your browser.
- Create a free Supabase account with your email address.
- Complete email verification if Supabase asks for it.
- Create a Free Plan project named subdivision-lot-map.
- Wait until the project dashboard confirms that the project is ready.
Your hosted database is ready. It now has a dashboard where staff can manage the inventory.
- Switch back to the subdivision-lot-map workspace in VS Code.
- Create database.sql in the VS Code Explorer sidebar.
- Define the inventory table by adding this first block to database.sql:
create table public.lots (
lot_id text primary key,
address text not null,
status text not null check (status in ('Available', 'Reserved', 'Sold')),
price integer not null,
beds integer not null,
baths numeric(3, 1) not null
);
What does this table store?
- The lot_id column gives every lot a unique identifier that the map can match later.
- The status check accepts only Available, Reserved, or Sold.
- The remaining columns hold the address, price, bedroom count, and bathroom count.
- Save database.sql.
- Switch back to your Supabase project in the browser.
- Open SQL Editor from the project sidebar.
- Create a blank query in SQL Editor.
- Paste the table definition from database.sql into the query.
- Execute the query with the SQL Editor control.
You'll see a success confirmation. The public.lots table now exists in the hosted database.
Table query did not succeed?
Confirm that you pasted the complete table definition. Check every closing parenthesis before executing it again.
If the table already exists from an earlier attempt, avoid running the creation block again.
Help me troubleshoot the lots table query
The table structure is empty until you seed its first inventory rows. The fictional Cedar View data gives the map all three status values to display.
- Switch back to database.sql in VS Code.
- Place your cursor below the table definition.
- Add the six inventory rows with this block:
insert into public.lots (lot_id, address, status, price, beds, baths)
values
('A1', '101 Cedar View Lane', 'Available', 425000, 3, 2.0),
('A2', '103 Cedar View Lane', 'Reserved', 449000, 4, 2.5),
('A3', '105 Cedar View Lane', 'Sold', 472000, 4, 3.0),
('B1', '102 Cedar View Lane', 'Available', 418000, 3, 2.0),
('B2', '104 Cedar View Lane', 'Available', 455000, 4, 2.5),
('B3', '106 Cedar View Lane', 'Reserved', 489000, 4, 3.0);
What does this seed data provide?
- The A1 through B3 identifiers match the parcel identifiers used by the map.
- The rows include three Available lots.
- The rows also include Reserved and Sold examples for status-based map colors.
- Save database.sql.
- Return to SQL Editor in your Supabase project.
- Replace the current query with the insert block from database.sql.
- Execute the insert query.
- Open Table Editor from the project sidebar.
- Select the lots table.
You'll see six inventory rows in Table Editor. The first visible database result is now in place.
Missing one or more rows?
Confirm that the insert block contains all six identifiers from A1 through B3.
If rows already exist from an earlier run, keep the existing six rows without repeating the insert.
Help me troubleshoot the seed rows
The inventory is public information for this learning map. The access rules still need to limit visitors to reading rows.
- Return to database.sql in VS Code.
- Place your cursor below the insert block.
- Add the read-only access rules with this block:
alter table public.lots enable row level security;
revoke all on table public.lots from anon, authenticated;
grant select on table public.lots to anon, authenticated;
create policy "Anyone can read lots"
on public.lots for select
to anon, authenticated
using ( true );
How does public read-only access work?
- Enabling Row Level Security makes every client request pass through a policy.
- The revoke statement clears existing table privileges for the client roles.
- The grant select statement gives anon and authenticated permission to read rows.
- The Anyone can read lots policy allows those roles to read every inventory row.
- Save database.sql.
- Return to SQL Editor in your Supabase project.
- Replace the current query with the access block from database.sql.
- Execute the access query.
You'll see a success confirmation. Public client roles can now read the inventory without receiving write privileges.
Access query did not succeed?
Confirm that public.lots exists before applying its access rules. Check that every role name matches the block exactly.
Help me troubleshoot the access policy
✔️ Awesome, I've got everything!
Great. Save database.sql before moving on to the project connection values.
ⓧ I'd like to double check the full code
Compare your complete database.sql file with this reference.
create table public.lots (
lot_id text primary key,
address text not null,
status text not null check (status in ('Available', 'Reserved', 'Sold')),
price integer not null,
beds integer not null,
baths numeric(3, 1) not null
);
insert into public.lots (lot_id, address, status, price, beds, baths)
values
('A1', '101 Cedar View Lane', 'Available', 425000, 3, 2.0),
('A2', '103 Cedar View Lane', 'Reserved', 449000, 4, 2.5),
('A3', '105 Cedar View Lane', 'Sold', 472000, 4, 3.0),
('B1', '102 Cedar View Lane', 'Available', 418000, 3, 2.0),
('B2', '104 Cedar View Lane', 'Available', 455000, 4, 2.5),
('B3', '106 Cedar View Lane', 'Reserved', 489000, 4, 3.0);
alter table public.lots enable row level security;
revoke all on table public.lots from anon, authenticated;
grant select on table public.lots to anon, authenticated;
create policy "Anyone can read lots"
on public.lots for select
to anon, authenticated
using ( true );
Store the project connection values
The application needs the Supabase project URL and publishable key before it can request inventory. The existing .env.example file provides the required variable names.
This part handles a project credential. Your existing .gitignore file excludes .env from source control.
- Select .env.example in the VS Code Explorer sidebar.
- Press Ctrl+C to copy the file.
- Press Ctrl+V to create a copy.
- Rename the copied file to .env.
Why use a separate .env file?
The .env.example file documents the required variable names with safe placeholders. The local .env file stores values for your project.
Keeping .env in .gitignore prevents those values from entering a Git commit.
- Return to your Supabase project in the browser.
- Open the Connect dialog.
- Copy the project URL.
- Switch back to .env in VS Code.
- Replace https://your-project.supabase.co with the copied project URL.
The first line of .env now points to your hosted Supabase project.
Which key belongs in .env?
Use the publishable key from the Connect dialog. Publishable keys use the sb_publishable_... format.
This project combines that low-privilege key with the table's read-only grant and policy. Visitors receive inventory without database write access.
- Return to the Connect dialog in Supabase.
- Copy the publishable key.
- Switch back to .env in VS Code.
- Replace sb_publishable_your-key-here with the copied publishable key.
- Save .env.
- Open .gitignore from the VS Code Explorer.
You'll see .env on its own line. That entry keeps the project connection values out of source control.
Connection placeholders still visible?
Open the Connect dialog again if you copied the wrong value. The URL replaces the first placeholder.
The publishable key replaces the second placeholder. Avoid sharing the completed .env file in screenshots or messages.
Help me check my .env safely
Verify the six live lots
A row-count query proves that the seed reached the hosted database. Table Editor provides a second check for the identifiers and status values.
- Return to .env in VS Code.
- Confirm that both original placeholder values have been replaced.
- Switch back to your Supabase project in the browser.
Before you check, pause on the row count you expect to see.
- Open SQL Editor from the project sidebar.
- Create a blank query in SQL Editor.
- Enter select count(*) from public.lots; into the query.
- Execute the count query.
You'll see a count of 6. That's the hosted inventory confirmed with every seeded row present.
- Open Table Editor from the project sidebar.
- Select the lots table under the public schema.
You'll see A1, A2, A3, B1, B2, and B3. The status column includes Available, Reserved, and Sold values.
Your online inventory now has six secured rows plus local connection settings. Next, you'll draw the parcel map that gives those lot identifiers a visible shape.
Draw the First Parcel Map
Your six inventory rows now live in Supabase. Visitors still need a visual surface that shows where each fictional lot sits.
A starter FastAPI application will serve the map page. MapLibre GL JS will draw local GeoJSON parcel shapes over a basemap.
In this step, get ready to:
- Create a starter FastAPI application that serves the local frontend.
- Build the page layout and load the MapLibre demo basemap.
- Draw six synthetic parcel polygons from local GeoJSON.
Serve the starter frontend
The browser needs one local address for the page files and parcel data. Serving both through FastAPI keeps every resource on the same origin.
- Select the subdivision-lot-map workspace folder in the VS Code Explorer sidebar.
- Create a folder named static inside subdivision-lot-map with the new-folder icon at the top of the sidebar.
You should see the new static folder in the Explorer sidebar.
- Create main.py inside subdivision-lot-map with the new-file icon at the top of the sidebar.
- Add the starter server code to main.py by pasting the following:
from fastapi import FastAPI
app = FastAPI(title="Subdivision Lot Map")
app.frontend("/", directory="static")
What does this code do?
- The import makes the FastAPI application class available.
- The app variable holds your local web application.
- The app.frontend() call serves files from static at the site root.
- Save main.py with Ctrl+S.
- Return to the active VS Code PowerShell terminal.
- Start the local development server by running this command:
uvicorn main:app --reload
What does this command do?
Uvicorn imports the app object from main.py. Reload mode restarts the server after saved code changes.
The terminal remains occupied while the server listens for requests. Keep this process running throughout the step.
You should see output showing that the server is running at http://127.0.0.1:8000.
Server not starting?
Confirm that the active terminal is inside subdivision-lot-map. Check that main.py sits beside the static folder.
Help me troubleshoot the starter server
The running server can now deliver a page from static. The page needs a map container and links to its stylesheet and JavaScript module.
- Create index.html inside the static folder with the new-file icon.
- Add the page structure to static/index.html by pasting the following:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cedar View Lot Map</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.css">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Cedar View</p>
<h1>Subdivision Lot Map</h1>
</div>
<p id="connection-status" class="status-message">Loading parcel map...</p>
</header>
<main class="layout">
<section class="map-card" aria-label="Interactive subdivision map">
<div id="map"></div>
</section>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
What does this page contain?
- The header names the fictional Cedar View development.
- The connection-status element gives JavaScript a place to report progress.
- The map element provides the container where MapLibre renders.
- The links connect the page to the MapLibre stylesheet and your local files.
- Save static/index.html with Ctrl+S.
- Enter http://127.0.0.1:8000 in your browser address bar.
You should see the Cedar View heading with the loading message. This first browser response proves FastAPI is serving your frontend.
Page not loading?
Keep the Uvicorn terminal running while you use the browser. Confirm that index.html sits directly inside static.
Help me troubleshoot the local page
Style the page and load MapLibre
The page structure gives the browser a map container. CSS gives that container enough height for the basemap to render.
- Create styles.css inside the static folder with the new-file icon.
- Add the page foundation to static/styles.css by pasting the following:
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #17212b;
background: #eef2f5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.5rem;
color: white;
background: #17324d;
}
What does this styling establish?
- The root selector sets the page colors and font stack.
- The universal selector keeps element dimensions predictable.
- The header selector creates a dark banner for the title and loading status.
- Save static/styles.css with Ctrl+S.
- Refresh the browser page.
You should see a dark blue header above a pale page background.
Still seeing a plain page?
Confirm that styles.css sits directly inside static. Check that the local stylesheet link in index.html uses /styles.css.
Help me fix the missing page styles
- Append the typography and status styles to static/styles.css by pasting the following below the existing code:
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(1.4rem, 3vw, 2rem);
}
.eyebrow {
margin-bottom: 0.25rem;
color: #7ad7c4;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.status-message {
margin: 0;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
}
What changes in the header?
- The main heading scales with the browser width.
- The eyebrow label becomes a compact uppercase marker.
- The status message gains a rounded translucent background.
- Save static/styles.css with Ctrl+S.
- Refresh the browser page.
You should see a teal Cedar View label beside a rounded loading badge.
- Append the map layout styles to static/styles.css by pasting the following below the existing code:
.layout {
padding: 1rem;
}
.map-card {
position: relative;
overflow: hidden;
border-radius: 16px;
background: white;
box-shadow: 0 12px 30px rgba(23, 50, 77, 0.12);
}
#map {
min-height: 72vh;
}
Why does the map need a height?
The map element needs a visible height before MapLibre can draw inside it. The surrounding card clips the basemap to rounded corners.
- Save static/styles.css with Ctrl+S.
- Refresh the browser page.
You should see a large white card beneath the header.
Map card has no height?
Confirm that the final selector is #map. Check that index.html contains the matching map identifier.
Help me fix the map container height
The container is ready for MapLibre GL JS 6.13.0. Its demo style supplies geographic context without requiring another account.
- Create app.js inside the static folder with the new-file icon.
- Add the map initialization code to static/app.js by pasting the following:
import * as maplibregl from "https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs";
const statusMessage = document.querySelector("#connection-status");
const map = new maplibregl.Map({
container: "map",
style: "https://demotiles.maplibre.org/style.json",
center: [-97.7447, 30.2681],
zoom: 16,
maplibreLogo: true,
});
How is the map initialized?
- The module import loads MapLibre GL JS in the browser.
- The map uses the map element as its container.
- The demo style supplies the visible basemap.
- The center and zoom values position the view around the synthetic parcels.
Why MapLibre for this map?
MapLibre can load its documented demo style without a second account. This keeps the project focused on connecting parcel geometry to online inventory.
- Save static/app.js with Ctrl+S.
- Refresh the browser page.
You should see roads and place labels inside the rounded map card.
Basemap staying blank?
Confirm that app.js sits directly inside static. Check that the module URL and demo style URL match the code block exactly.
Help me troubleshoot the blank basemap
Add the parcel data and outline every lot
The basemap provides geographic context. Each GeoJSON feature adds one synthetic parcel polygon with a lot_id that matches a row in the online inventory.
Why are there two parcel chunks?
The complete FeatureCollection is longer than a useful editing chunk. The first block intentionally leaves the feature array open.
The second block immediately adds the remaining parcels and closes the JSON document. Save the file after both blocks are present.
- Create lots.geojson inside the static folder with the new-file icon.
- Add the first three parcel features to static/lots.geojson by pasting the following:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "lot_id": "A1" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7460, 30.2690], [-97.7452, 30.2690], [-97.7452, 30.2684], [-97.7460, 30.2684], [-97.7460, 30.2690]]] }
},
{
"type": "Feature",
"properties": { "lot_id": "A2" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7451, 30.2690], [-97.7443, 30.2690], [-97.7443, 30.2684], [-97.7451, 30.2684], [-97.7451, 30.2690]]] }
},
{
"type": "Feature",
"properties": { "lot_id": "A3" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7442, 30.2690], [-97.7434, 30.2690], [-97.7434, 30.2684], [-97.7442, 30.2684], [-97.7442, 30.2690]]] }
},
What is in this first half?
Each feature stores a lot identifier with a closed polygon boundary. The trailing comma keeps the feature array ready for the remaining three parcels.
- Complete static/lots.geojson by pasting the following directly below the first chunk:
{
"type": "Feature",
"properties": { "lot_id": "B1" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7460, 30.2682], [-97.7452, 30.2682], [-97.7452, 30.2676], [-97.7460, 30.2676], [-97.7460, 30.2682]]] }
},
{
"type": "Feature",
"properties": { "lot_id": "B2" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7451, 30.2682], [-97.7443, 30.2682], [-97.7443, 30.2676], [-97.7451, 30.2676], [-97.7451, 30.2682]]] }
},
{
"type": "Feature",
"properties": { "lot_id": "B3" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7442, 30.2682], [-97.7434, 30.2682], [-97.7434, 30.2676], [-97.7442, 30.2676], [-97.7442, 30.2682]]] }
}
]
}
How is the collection completed?
The second half adds lots B1, B2, and B3. The final brackets close the feature array and FeatureCollection.
- Save static/lots.geojson with Ctrl+S.
- Enter http://127.0.0.1:8000/lots.geojson in the browser address bar.
You should see a FeatureCollection containing lot identifiers A1, A2, A3, B1, B2, and B3.
Parcel file not opening?
Confirm that the file is named lots.geojson inside static. Check the comma between the A3 and B1 features.
Help me fix the GeoJSON file
The parcel data is now available from the same server as the page. A map load handler can fetch the FeatureCollection and draw a temporary fill for every polygon.
- Return to static/app.js in the VS Code editor.
- Add the parcel loading handler below the map constructor by pasting the following:
map.on("load", async () => {
const response = await fetch("/lots.geojson");
const lots = await response.json();
map.addSource("lots", {
type: "geojson",
data: lots,
});
map.addLayer({
id: "lot-fills",
type: "fill",
source: "lots",
paint: {
"fill-color": "#6b7280",
"fill-opacity": 0.25,
},
});
statusMessage.textContent = "Six parcel shapes loaded";
});
How does the fill layer work?
- The load event waits until the basemap is ready.
- The browser fetches /lots.geojson and decodes the FeatureCollection.
- The lots source stores the six parcel features.
- The fill layer draws every polygon with the same temporary gray color.
- Save static/app.js with Ctrl+S.
- Refresh http://127.0.0.1:8000 in your browser.
You should see six translucent gray parcel fills arranged in two rows. The header should report Six parcel shapes loaded.
The fills prove that MapLibre can read the local geometry. A separate line layer will make every parcel boundary easy to distinguish.
- Find the statusMessage.textContent = "Six parcel shapes loaded"; line in static/app.js.
- Add the border layer directly above that line by pasting the following:
map.addLayer({
id: "lot-borders",
type: "line",
source: "lots",
paint: {
"line-color": "#ffffff",
"line-width": 2,
},
});
What does the border layer add?
The line layer uses the same lots source as the fill layer. A white two-pixel line traces each polygon boundary.
- Save static/app.js with Ctrl+S.
Before you refresh, consider whether all six parcel outlines will appear over the basemap.
- Return to http://127.0.0.1:8000 in your browser.
- Refresh the map page.
You should see six translucent gray polygons with crisp white outlines over the demo basemap. The header should display Six parcel shapes loaded.
That is your first working parcel map. Every fictional lot now has a visible shape with a shared lot_id ready for live inventory data.
No outlined parcels on the map?
Confirm that the load handler appears after the map constructor. Check that fetch("/lots.geojson") matches the parcel filename.
Help me troubleshoot the missing parcels
✔️ Awesome, I've got everything!
Your starter server and map files are saved. Keep Uvicorn running for the next step.
ⓧ I'd like to double check the full code
Compare each file created in this step with the complete versions below.
- Compare main.py with this complete version:
from fastapi import FastAPI
app = FastAPI(title="Subdivision Lot Map")
app.frontend("/", directory="static")
This file creates the FastAPI application and serves the static directory.
- Compare static/index.html with this complete version:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cedar View Lot Map</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.css">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Cedar View</p>
<h1>Subdivision Lot Map</h1>
</div>
<p id="connection-status" class="status-message">Loading parcel map...</p>
</header>
<main class="layout">
<section class="map-card" aria-label="Interactive subdivision map">
<div id="map"></div>
</section>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
This page provides the header and map container. It also loads the local stylesheet and JavaScript module.
- Compare static/styles.css with this complete version:
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #17212b;
background: #eef2f5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.5rem;
color: white;
background: #17324d;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(1.4rem, 3vw, 2rem);
}
.eyebrow {
margin-bottom: 0.25rem;
color: #7ad7c4;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.status-message {
margin: 0;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
}
.layout {
padding: 1rem;
}
.map-card {
position: relative;
overflow: hidden;
border-radius: 16px;
background: white;
box-shadow: 0 12px 30px rgba(23, 50, 77, 0.12);
}
#map {
min-height: 72vh;
}
These styles create the header and loading badge. They also give the map a visible card with enough height for MapLibre to render.
- Compare static/app.js with this complete version:
import * as maplibregl from "https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs";
const statusMessage = document.querySelector("#connection-status");
const map = new maplibregl.Map({
container: "map",
style: "https://demotiles.maplibre.org/style.json",
center: [-97.7447, 30.2681],
zoom: 16,
maplibreLogo: true,
});
map.on("load", async () => {
const response = await fetch("/lots.geojson");
const lots = await response.json();
map.addSource("lots", {
type: "geojson",
data: lots,
});
map.addLayer({
id: "lot-fills",
type: "fill",
source: "lots",
paint: {
"fill-color": "#6b7280",
"fill-opacity": 0.25,
},
});
map.addLayer({
id: "lot-borders",
type: "line",
source: "lots",
paint: {
"line-color": "#ffffff",
"line-width": 2,
},
});
statusMessage.textContent = "Six parcel shapes loaded";
});
This script initializes the basemap and loads the local FeatureCollection. Separate fill and line layers draw the six parcels.
- Compare static/lots.geojson with this complete version:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "lot_id": "A1" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7460, 30.2690], [-97.7452, 30.2690], [-97.7452, 30.2684], [-97.7460, 30.2684], [-97.7460, 30.2690]]] }
},
{
"type": "Feature",
"properties": { "lot_id": "A2" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7451, 30.2690], [-97.7443, 30.2690], [-97.7443, 30.2684], [-97.7451, 30.2684], [-97.7451, 30.2690]]] }
},
{
"type": "Feature",
"properties": { "lot_id": "A3" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7442, 30.2690], [-97.7434, 30.2690], [-97.7434, 30.2684], [-97.7442, 30.2684], [-97.7442, 30.2690]]] }
},
{
"type": "Feature",
"properties": { "lot_id": "B1" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7460, 30.2682], [-97.7452, 30.2682], [-97.7452, 30.2676], [-97.7460, 30.2676], [-97.7460, 30.2682]]] }
},
{
"type": "Feature",
"properties": { "lot_id": "B2" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7451, 30.2682], [-97.7443, 30.2682], [-97.7443, 30.2676], [-97.7451, 30.2676], [-97.7451, 30.2682]]] }
},
{
"type": "Feature",
"properties": { "lot_id": "B3" },
"geometry": { "type": "Polygon", "coordinates": [[[-97.7442, 30.2682], [-97.7434, 30.2682], [-97.7434, 30.2676], [-97.7442, 30.2676], [-97.7442, 30.2682]]] }
}
]
}
The FeatureCollection contains six synthetic polygons. Each feature carries the lot identifier used by the online inventory.
Your local parcel map is running from FastAPI. Next, you will make the browser request live inventory and expose the missing backend connection.
Request Live Inventory
Your MapLibre GL JS map now displays six parcel shapes from local GeoJSON. Each polygon only carries a lot_id value.
The useful inventory fields live in Supabase. This step makes the browser request the local geometry and online inventory together so you can test what the current backend provides.
In this step, get ready to:
- Request parcel geometry and live inventory during the same map load.
- Handle a failed inventory request with a readable page message.
- Confirm the missing backend route through the browser Network panel.
Request both data sources
The map currently requests only /lots.geojson. A dedicated loadLots() function can start the geometry request and inventory request as one loading operation.
- Return to static/app.js in the VS Code editor.
- Place your cursor below the closing }); for the map constructor.
- Add the loading function by copying this code:
async function loadLots() {
const [geometryResponse, inventoryResponse] = await Promise.all([
fetch("/lots.geojson"),
fetch("/api/lots"),
]);
if (!geometryResponse.ok || !inventoryResponse.ok) {
throw new Error("Live inventory could not be loaded.");
}
return geometryResponse.json();
}
What does this function do?
- The two requests begin as one loading operation so the map depends on both sources.
- The response check stops the load when either request fails.
- The geometry is returned only after both responses succeed.
- Find these two lines inside the existing map load handler:
const response = await fetch("/lots.geojson");
const lots = await response.json();
These lines represent the old geometry-only request. They bypass the new inventory loading function.
- Replace those two lines with this line:
const lots = await loadLots();
The map load handler now calls loadLots(). That call tests both resources before adding the parcel source.
- Save static/app.js.
- Switch back to the browser from earlier.
- Open your browser's developer tools.
- Select the Network panel.
Before you refresh, do you think both requests can succeed with the backend in its current state?
- Refresh http://127.0.0.1:8000.
You'll see /lots.geojson complete successfully. You'll see /api/lots return 404.
This shortfall is intentional
The browser has started asking for live inventory. The current FastAPI application only serves the frontend, so no route can answer that inventory request.
The failed request gives you direct evidence of the missing connection. The next task makes that evidence readable on the page.
Don't see the inventory request?
Confirm that static/app.js is saved. Check that the map load handler calls loadLots().
Refresh the page while the Network panel is open.
Help me find why the inventory request is missing.
Display the failed request
A rejected loading operation needs an error handler. A try...catch block lets the page replace its loading text with a clear message when the inventory request fails.
- Return to the map load handler in static/app.js.
- Find the opening lines shown here:
map.on("load", async () => {
const lots = await loadLots();
The handler currently lets a failed request escape without updating the page.
- Replace those opening lines with this version:
map.on("load", async () => {
try {
const lots = await loadLots();
The try block now contains the map work that depends on both responses.
- Find the final status update and closing line at the bottom of the handler:
statusMessage.textContent = "Six parcel shapes loaded";
});
This old ending reports success after a geometry-only load. It does not describe the failed inventory request.
- Replace that ending with this error handler:
} catch (error) {
console.error(error);
statusMessage.textContent = "Live inventory could not be loaded.";
statusMessage.classList.add("error");
}
});
How does the error handler help?
- The caught error is written to the browser console for debugging.
- The status message tells visitors that live inventory is unavailable.
- The error class gives the page a styling hook for the failed state.
- Save static/app.js.
Before you refresh, what message do you expect the page to show after the inventory request fails?
- Refresh the browser page.
You'll see Live inventory could not be loaded. in the page header. The Network panel still shows /api/lots returning 404.
- Return to static/app.js.
- Select the lines from map.addSource("lots", { through the closing }); for the second map layer.
- Press Tab once to indent those lines inside the try block.
- Save static/app.js.
- Refresh the browser to confirm the readable inventory error remains visible.
Still seeing the old parcel message?
Check that the old Six parcel shapes loaded assignment has been removed. Confirm that the catch block sets the new inventory message.
Help me fix the missing page error.
Style the loading and error states
The header status should describe the live inventory request from the moment loading begins. A darker red background makes the failed state easy to distinguish from normal loading.
- Return to static/index.html in VS Code.
- Find the current status element:
<p id="connection-status" class="status-message">Loading parcel map...</p>
The current text describes only the local parcel map. The browser now waits for live inventory as part of the same load.
- Replace that element with this version:
<p id="connection-status" class="status-message">Loading live inventory...</p>
The initial page state now names the resource that controls whether the combined load succeeds.
- Return to static/styles.css.
- Add the error style below the existing .status-message rule:
.status-message.error {
background: #9f2d2d;
}
What does this style change?
The rule activates when JavaScript adds the error class. The darker background turns the header message into a visible failure state.
- Save static/index.html.
- Save static/styles.css.
- Switch back to the browser.
- Keep the Network panel visible.
Before you refresh, which request do you expect to expose the missing backend connection?
- Refresh http://127.0.0.1:8000.
You'll see /api/lots return 404 in the Network panel. The page header shows Live inventory could not be loaded. on a dark red background.
You have made the missing connection visible in both the browser request log and the page interface. That evidence gives the backend work a clear target.
✔️ Awesome, I've got everything!
Great work. Your frontend now requests live inventory and reports the planned failure clearly.
ⓧ I'd like to double check the full code
Compare your three edited frontend files with these complete versions.
import * as maplibregl from "https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs";
const statusMessage = document.querySelector("#connection-status");
const map = new maplibregl.Map({
container: "map",
style: "https://demotiles.maplibre.org/style.json",
center: [-97.7447, 30.2681],
zoom: 16,
maplibreLogo: true,
});
async function loadLots() {
const [geometryResponse, inventoryResponse] = await Promise.all([
fetch("/lots.geojson"),
fetch("/api/lots"),
]);
if (!geometryResponse.ok || !inventoryResponse.ok) {
throw new Error("Live inventory could not be loaded.");
}
return geometryResponse.json();
}
map.on("load", async () => {
try {
const lots = await loadLots();
map.addSource("lots", {
type: "geojson",
data: lots,
});
map.addLayer({
id: "lot-fills",
type: "fill",
source: "lots",
paint: {
"fill-color": "#6b7280",
"fill-opacity": 0.25,
},
});
map.addLayer({
id: "lot-borders",
type: "line",
source: "lots",
paint: {
"line-color": "#ffffff",
"line-width": 2,
},
});
} catch (error) {
console.error(error);
statusMessage.textContent = "Live inventory could not be loaded.";
statusMessage.classList.add("error");
}
});
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cedar View Lot Map</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.css">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Cedar View</p>
<h1>Subdivision Lot Map</h1>
</div>
<p id="connection-status" class="status-message">Loading live inventory...</p>
</header>
<main class="layout">
<section class="map-card" aria-label="Interactive subdivision map">
<div id="map"></div>
</section>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #17212b;
background: #eef2f5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.5rem;
color: white;
background: #17324d;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(1.4rem, 3vw, 2rem);
}
.eyebrow {
margin-bottom: 0.25rem;
color: #7ad7c4;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.status-message {
margin: 0;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
}
.status-message.error {
background: #9f2d2d;
}
.layout {
padding: 1rem;
}
.map-card {
position: relative;
overflow: hidden;
border-radius: 16px;
background: white;
box-shadow: 0 12px 30px rgba(23, 50, 77, 0.12);
}
#map {
min-height: 72vh;
}
The frontend now asks for the inventory it needs and exposes the missing route. Next, you'll give FastAPI a safe path to the online lot data.
Connect and Style Live Lots
Your map now requests live inventory. The planned 404 proved that the browser has no backend route for reaching the online data.
In this step, you will turn FastAPI into a safe bridge to Supabase. You will then join each inventory row to its GeoJSON parcel before MapLibre colors the map.
In this step, get ready to:
- Add a read-only inventory route that calls the Supabase REST API through HTTPX.
- Join the six inventory rows to parcel features through lot_id.
- Add status colors with click details to the live map.
Connect FastAPI to Supabase
The browser needs a same-origin endpoint that can read the hosted inventory. HTTPX lets the backend call the Supabase REST API with the low-privilege publishable key from .env.
- Switch back to main.py in the open VS Code workspace.
- Replace the imports through the app declaration with this code:
import os
import httpx
from dotenv import load_dotenv
from fastapi import FastAPI
load_dotenv()
url = os.environ.get("SUPABASE_URL")
publishable_key = os.environ.get("SUPABASE_PUBLISHABLE_KEY")
if not url or not publishable_key:
raise RuntimeError(
"Add SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY to .env before starting the app."
)
app = FastAPI(title="Subdivision Lot Map")
How does the configuration work?
- The load_dotenv() call loads the local values from .env into the application environment.
- The url value identifies your Supabase project.
- The publishable_key value authorizes the public read request without granting elevated database access.
- The configuration check stops the app with a readable message when either required value is missing.
- Save main.py by pressing Ctrl+S.
- Return to the VS Code PowerShell terminal from earlier to confirm Uvicorn reloads without a configuration error.
You should see Uvicorn reload the application. The running process now has access to the project URL and publishable key.
Did the reload stop?
Confirm that .env remains inside the subdivision-lot-map folder. Check that both environment variable names match the code exactly.
Check for missing quotation marks or parentheses near the configuration check in main.py.
Help me fix the FastAPI configuration reload
The configuration is available to the app. The missing route can now request the six rows from the hosted lots table.
- Insert this route between the app declaration and app.frontend() in main.py:
@app.get("/api/lots")
def read_lots():
response = httpx.get(
f"{url.rstrip('/')}/rest/v1/lots",
headers={"apikey": publishable_key},
params={"select": "*"},
timeout=10.0,
)
response.raise_for_status()
return response.json()
What does this route do?
- The @app.get("/api/lots") decorator gives the browser the route that previously returned 404.
- The request appends /rest/v1/lots to your project URL to reach the hosted table.
- The apikey header carries the publishable key used by the read-only database policy.
- The response check prevents a failed database request from being returned as successful inventory data.
- Save main.py.
- Return to the VS Code PowerShell terminal to confirm Uvicorn reloads the route.
Before you check the endpoint, what do you expect to replace the previous 404 response?
- Enter http://127.0.0.1:8000/api/lots in the browser from earlier.
You should see a JSON array containing six records. Each record includes its lot ID, address, status, price, bedroom count, and bathroom count.
The endpoint does not show six records?
Return to the Uvicorn terminal to check whether the request reached FastAPI. A database request failure usually points to an incorrect project URL, publishable key, or read policy.
Confirm that the apikey header uses publishable_key. Keep the publishable key private when asking for help.
Help me troubleshoot the inventory endpoint
Join inventory to parcel geometry
The endpoint now returns inventory rows. The map still needs to match those rows to polygon features through their shared lot_id values.
- Switch back to static/app.js in VS Code.
- Replace the existing statusMessage declaration with these page references and currency settings:
const statusMessage = document.querySelector("#connection-status");
const lotTitle = document.querySelector("#lot-title");
const lotDetails = document.querySelector("#lot-details");
const lotCount = document.querySelector("#lot-count");
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
What do these references prepare?
- The three new page references connect the script to the upcoming title, details panel, and lot count.
- The currency formatter converts numeric prices into readable US dollar values.
- Save static/app.js.
- Refresh the map in the browser to send both data requests through the working backend.
The Network panel should show successful responses for /lots.geojson and /api/lots. The six parcels remain visible with their temporary gray styling.
- Insert these display helpers above loadLots() in static/app.js:
function escapeHtml(value) {
const node = document.createElement("div");
node.textContent = String(value ?? "");
return node.innerHTML;
}
function showLot(properties) {
const lotId = escapeHtml(properties.lot_id);
const status = escapeHtml(properties.status);
const address = escapeHtml(properties.address);
const price = currency.format(Number(properties.price));
const plan = `${escapeHtml(properties.beds)} beds / ${escapeHtml(properties.baths)} baths`;
lotTitle.textContent = `Lot ${properties.lot_id}`;
lotDetails.innerHTML = `
<div><dt>Status</dt><dd>${status}</dd></div>
<div><dt>Address</dt><dd>${address}</dd></div>
<div><dt>Price</dt><dd>${price}</dd></div>
<div><dt>Plan</dt><dd>${plan}</dd></div>
`;
return `<strong>Lot ${lotId}</strong><br>${status}<br>${address}<br>${price}`;
}
How are lot details prepared?
- The escapeHtml() helper converts each database value into safe display text before it enters popup markup.
- The showLot() helper formats one parcel's inventory fields for the side panel.
- The returned HTML gives the same selected lot a compact map popup.
- Find the final return statement inside loadLots().
- Replace that statement with this inventory join:
const geometry = await geometryResponse.json();
const inventory = await inventoryResponse.json();
const inventoryById = new Map(
inventory.map((lot) => [lot.lot_id, lot]),
);
geometry.features = geometry.features.map((feature) => ({
...feature,
properties: {
...feature.properties,
...(inventoryById.get(feature.properties.lot_id) ?? {
status: "Unknown",
address: "No inventory match",
price: 0,
beds: "-",
baths: "-",
}),
},
}));
return geometry;
How does the join work?
- The two response bodies become separate geometry and inventory collections.
- The inventoryById map indexes each online row by lot_id for direct lookup.
- Each GeoJSON feature receives the matching address, status, price, bedroom count, and bathroom count.
- The fallback values keep an unmatched parcel readable when an inventory row is missing.
- Save static/app.js.
- Refresh the browser to run the joined data flow.
You should see six parcels without the earlier failure message. The map source now carries the online inventory fields even though the temporary fill remains gray.
Did the parcels disappear?
Check that inventoryById uses lot.lot_id as its key. Confirm that the feature lookup uses feature.properties.lot_id.
Inspect the browser console for a line number in static/app.js. Compare the surrounding braces with the join code.
Help me debug the lot data join
Every feature now carries a live status. A data-driven match expression can translate those values into three distinct parcel colors.
- Replace the paint object inside the lot-fills layer with this code:
paint: {
"fill-color": [
"match",
["get", "status"],
"Available", "#2e7d32",
"Reserved", "#f9a825",
"Sold", "#c62828",
"#6b7280",
],
"fill-opacity": 0.66,
}
How do the colors follow live data?
- The match expression reads the enriched status property from every parcel.
- Available lots become green.
- Reserved lots become amber.
- Sold lots become red.
- Any unexpected status uses the gray fallback.
- Save static/app.js.
- Refresh the browser to apply the data-driven fill colors.
You should see three green parcels, two amber parcels, and one red parcel. Those colors now come from the online inventory joined through lot_id.
Add the map details interface
The colored parcels communicate status at a glance. The final interface adds a legend, a live count, a sample-data disclaimer, and selectable lot details.
- Switch back to static/index.html.
- Insert this legend immediately after the map element:
<div class="legend" aria-label="Lot status legend">
<span><i class="swatch available"></i>Available</span>
<span><i class="swatch reserved"></i>Reserved</span>
<span><i class="swatch sold"></i>Sold</span>
</div>
What does the legend add?
The legend gives visitors a visible key for the three database status values. Each swatch uses the same color assigned to its corresponding parcel fill.
- Insert this details panel after the closing tag for the map section:
<aside class="details-card">
<p class="eyebrow">Live inventory</p>
<h2 id="lot-title">Select a lot</h2>
<dl id="lot-details">
<div><dt>Status</dt><dd>Click a parcel</dd></div>
<div><dt>Address</dt><dd>-</dd></div>
<div><dt>Price</dt><dd>-</dd></div>
<div><dt>Plan</dt><dd>-</dd></div>
</dl>
<p id="lot-count" class="lot-count">0 lots loaded</p>
<p class="disclaimer">Demo inventory and synthetic parcel geometry for learning only.</p>
</aside>
What does the panel show?
- The title identifies the selected lot.
- The definition list provides fixed places for live status, address, price, and plan values.
- The count confirms how many inventory records reached the map.
- The disclaimer makes the fictional learning data explicit.
- Save static/index.html.
- Refresh the browser to reveal the new legend and inventory panel.
You should see the legend below the map area and a new details panel containing Select a lot with placeholder values.
- Switch back to static/styles.css.
- Replace the existing layout and card rules with this two-column layout:
.layout {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(260px, 1fr);
gap: 1rem;
padding: 1rem;
}
.map-card,
.details-card {
position: relative;
overflow: hidden;
border-radius: 16px;
background: white;
box-shadow: 0 12px 30px rgba(23, 50, 77, 0.12);
}
How does the page layout change?
The grid gives the map most of the available width. The details panel occupies a narrower second column while sharing the same card styling.
- Save static/styles.css.
- Refresh the browser to display the map beside the details panel.
You should see the large map card on the left and the inventory panel on the right.
- Add these legend structure rules below the #map rule:
.legend {
position: absolute;
z-index: 2;
left: 1rem;
bottom: 1rem;
display: grid;
gap: 0.4rem;
padding: 0.75rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.18);
}
.legend span {
display: flex;
align-items: center;
gap: 0.5rem;
}
.swatch {
width: 0.9rem;
height: 0.9rem;
border-radius: 3px;
}
How is the legend positioned?
The legend sits above the lower-left corner of the map. Its opaque background keeps the labels readable over the basemap.
- Add these status color rules below the swatch rule:
.available {
background: #2e7d32;
}
.reserved {
background: #f9a825;
}
.sold {
background: #c62828;
}
How do the swatches match the map?
Each class repeats the exact color used by the map's status expression. Visitors can connect every parcel fill to its inventory status.
- Save static/styles.css.
- Refresh the browser to check the styled legend.
You should see a white legend card with green, amber, and red swatches in the lower-left corner of the map.
- Add these details panel rules below the status color rules:
.details-card {
padding: 1.25rem;
}
.details-card dl {
display: grid;
gap: 0.75rem;
}
.details-card dl div {
padding-bottom: 0.75rem;
border-bottom: 1px solid #dfe6ec;
}
.details-card dt {
color: #657786;
font-size: 0.8rem;
}
.details-card dd {
margin: 0.2rem 0 0;
font-weight: 700;
}
How are the inventory fields organized?
The definition list separates each label from its value with consistent spacing. Borders make each inventory field easy to scan.
- Add the count, disclaimer, and popup rules below the details panel rules:
.lot-count {
font-weight: 800;
}
.disclaimer {
color: #657786;
font-size: 0.8rem;
line-height: 1.5;
}
.maplibregl-popup-content {
border-radius: 10px;
font: 14px/1.5 Inter, system-ui, sans-serif;
}
What do the finishing styles change?
The lot count receives extra emphasis. The disclaimer remains readable without competing with inventory details, while popups match the rounded card design.
- Save static/styles.css.
- Refresh the browser to check the finished desktop layout.
You should see separated detail rows, a bold lot count, and a smaller disclaimer inside the right-hand card.
- Add this responsive rule at the bottom of static/styles.css:
@media (max-width: 780px) {
.site-header {
align-items: flex-start;
flex-direction: column;
}
.layout {
grid-template-columns: 1fr;
}
#map {
min-height: 60vh;
}
}
How does the layout adapt?
The media rule changes the page to one column on narrower screens. It also reduces the map height so the details remain reachable.
- Save static/styles.css.
- Narrow the browser window to confirm the details panel moves below the map.
You should see the map and details panel stack vertically when the browser becomes narrow.
The interface is ready to display a selected feature. The final event handlers connect parcel clicks to showLot() and report a successful live connection.
- Switch back to static/app.js.
- Insert these event handlers after the lot-borders layer inside the successful try block:
map.on("click", "lot-fills", (event) => {
const feature = event.features?.[0];
if (!feature) return;
const popupHtml = showLot(feature.properties);
new maplibregl.Popup()
.setLngLat(event.lngLat)
.setHTML(popupHtml)
.addTo(map);
});
map.on("mouseenter", "lot-fills", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "lot-fills", () => {
map.getCanvas().style.cursor = "";
});
statusMessage.textContent = "Live inventory connected";
lotCount.textContent = `${lots.features.length} lots loaded`;
How does the map become interactive?
- The click handler sends the selected feature's enriched properties to showLot().
- The popup displays the selected lot at the clicked map coordinates.
- The hover handlers change the cursor while it is over a clickable parcel.
- The final messages confirm that six live inventory records reached the map.
- Save static/app.js.
Before you refresh, which page elements do you expect to change when you click a colored parcel?
- Refresh http://127.0.0.1:8000 in the browser.
- Click one colored parcel on the map.
You should see Live inventory connected in the header and 6 lots loaded in the details panel. The selected parcel should open a popup while the panel shows its live address, status, price, and plan.
Clicking a parcel shows no details?
Confirm that the click event targets lot-fills and passes feature.properties to showLot().
Check that lot-title, lot-details, and lot-count match the element IDs in static/index.html.
Help me debug the parcel click details
✔️ Awesome, I've got everything!
Great work. Save main.py, static/app.js, static/index.html, and static/styles.css.
ⓧ I'd like to double check the full code
Compare main.py with this completed backend file.
import os
import httpx
from dotenv import load_dotenv
from fastapi import FastAPI
load_dotenv()
url = os.environ.get("SUPABASE_URL")
publishable_key = os.environ.get("SUPABASE_PUBLISHABLE_KEY")
if not url or not publishable_key:
raise RuntimeError(
"Add SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY to .env before starting the app."
)
app = FastAPI(title="Subdivision Lot Map")
@app.get("/api/lots")
def read_lots():
response = httpx.get(
f"{url.rstrip('/')}/rest/v1/lots",
headers={"apikey": publishable_key},
params={"select": "*"},
timeout=10.0,
)
response.raise_for_status()
return response.json()
app.frontend("/", directory="static")
Compare static/index.html with this completed interface.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cedar View Lot Map</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.css">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="site-header">
<div>
<p class="eyebrow">Cedar View</p>
<h1>Subdivision Lot Map</h1>
</div>
<p id="connection-status" class="status-message">Loading live inventory...</p>
</header>
<main class="layout">
<section class="map-card" aria-label="Interactive subdivision map">
<div id="map"></div>
<div class="legend" aria-label="Lot status legend">
<span><i class="swatch available"></i>Available</span>
<span><i class="swatch reserved"></i>Reserved</span>
<span><i class="swatch sold"></i>Sold</span>
</div>
</section>
<aside class="details-card">
<p class="eyebrow">Live inventory</p>
<h2 id="lot-title">Select a lot</h2>
<dl id="lot-details">
<div><dt>Status</dt><dd>Click a parcel</dd></div>
<div><dt>Address</dt><dd>-</dd></div>
<div><dt>Price</dt><dd>-</dd></div>
<div><dt>Plan</dt><dd>-</dd></div>
</dl>
<p id="lot-count" class="lot-count">0 lots loaded</p>
<p class="disclaimer">Demo inventory and synthetic parcel geometry for learning only.</p>
</aside>
</main>
<script type="module" src="/app.js"></script>
</body>
</html>
Compare static/app.js with this completed map logic.
import * as maplibregl from "https://unpkg.com/maplibre-gl@6.13.0/dist/maplibre-gl.mjs";
const statusMessage = document.querySelector("#connection-status");
const lotTitle = document.querySelector("#lot-title");
const lotDetails = document.querySelector("#lot-details");
const lotCount = document.querySelector("#lot-count");
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const map = new maplibregl.Map({
container: "map",
style: "https://demotiles.maplibre.org/style.json",
center: [-97.7447, 30.2681],
zoom: 16,
maplibreLogo: true,
});
function escapeHtml(value) {
const node = document.createElement("div");
node.textContent = String(value ?? "");
return node.innerHTML;
}
function showLot(properties) {
const lotId = escapeHtml(properties.lot_id);
const status = escapeHtml(properties.status);
const address = escapeHtml(properties.address);
const price = currency.format(Number(properties.price));
const plan = `${escapeHtml(properties.beds)} beds / ${escapeHtml(properties.baths)} baths`;
lotTitle.textContent = `Lot ${properties.lot_id}`;
lotDetails.innerHTML = `
<div><dt>Status</dt><dd>${status}</dd></div>
<div><dt>Address</dt><dd>${address}</dd></div>
<div><dt>Price</dt><dd>${price}</dd></div>
<div><dt>Plan</dt><dd>${plan}</dd></div>
`;
return `<strong>Lot ${lotId}</strong><br>${status}<br>${address}<br>${price}`;
}
async function loadLots() {
const [geometryResponse, inventoryResponse] = await Promise.all([
fetch("/lots.geojson"),
fetch("/api/lots"),
]);
if (!geometryResponse.ok || !inventoryResponse.ok) {
throw new Error("Live inventory could not be loaded.");
}
const geometry = await geometryResponse.json();
const inventory = await inventoryResponse.json();
const inventoryById = new Map(
inventory.map((lot) => [lot.lot_id, lot]),
);
geometry.features = geometry.features.map((feature) => ({
...feature,
properties: {
...feature.properties,
...(inventoryById.get(feature.properties.lot_id) ?? {
status: "Unknown",
address: "No inventory match",
price: 0,
beds: "-",
baths: "-",
}),
},
}));
return geometry;
}
map.on("load", async () => {
try {
const lots = await loadLots();
map.addSource("lots", {
type: "geojson",
data: lots,
});
map.addLayer({
id: "lot-fills",
type: "fill",
source: "lots",
paint: {
"fill-color": [
"match",
["get", "status"],
"Available", "#2e7d32",
"Reserved", "#f9a825",
"Sold", "#c62828",
"#6b7280",
],
"fill-opacity": 0.66,
},
});
map.addLayer({
id: "lot-borders",
type: "line",
source: "lots",
paint: {
"line-color": "#ffffff",
"line-width": 2,
},
});
map.on("click", "lot-fills", (event) => {
const feature = event.features?.[0];
if (!feature) return;
const popupHtml = showLot(feature.properties);
new maplibregl.Popup()
.setLngLat(event.lngLat)
.setHTML(popupHtml)
.addTo(map);
});
map.on("mouseenter", "lot-fills", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "lot-fills", () => {
map.getCanvas().style.cursor = "";
});
statusMessage.textContent = "Live inventory connected";
lotCount.textContent = `${lots.features.length} lots loaded`;
} catch (error) {
console.error(error);
statusMessage.textContent = "Live inventory could not be loaded.";
statusMessage.classList.add("error");
}
});
Compare static/styles.css with this completed stylesheet.
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #17212b;
background: #eef2f5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.5rem;
color: white;
background: #17324d;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 0;
font-size: clamp(1.4rem, 3vw, 2rem);
}
.eyebrow {
margin-bottom: 0.25rem;
color: #7ad7c4;
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.status-message {
margin: 0;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
}
.status-message.error {
background: #9f2d2d;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(260px, 1fr);
gap: 1rem;
padding: 1rem;
}
.map-card,
.details-card {
position: relative;
overflow: hidden;
border-radius: 16px;
background: white;
box-shadow: 0 12px 30px rgba(23, 50, 77, 0.12);
}
#map {
min-height: 72vh;
}
.legend {
position: absolute;
z-index: 2;
left: 1rem;
bottom: 1rem;
display: grid;
gap: 0.4rem;
padding: 0.75rem;
border-radius: 10px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 5px 18px rgba(0, 0, 0, 0.18);
}
.legend span {
display: flex;
align-items: center;
gap: 0.5rem;
}
.swatch {
width: 0.9rem;
height: 0.9rem;
border-radius: 3px;
}
.available {
background: #2e7d32;
}
.reserved {
background: #f9a825;
}
.sold {
background: #c62828;
}
.details-card {
padding: 1.25rem;
}
.details-card dl {
display: grid;
gap: 0.75rem;
}
.details-card dl div {
padding-bottom: 0.75rem;
border-bottom: 1px solid #dfe6ec;
}
.details-card dt {
color: #657786;
font-size: 0.8rem;
}
.details-card dd {
margin: 0.2rem 0 0;
font-weight: 700;
}
.lot-count {
font-weight: 800;
}
.disclaimer {
color: #657786;
font-size: 0.8rem;
line-height: 1.5;
}
.maplibregl-popup-content {
border-radius: 10px;
font: 14px/1.5 Inter, system-ui, sans-serif;
}
@media (max-width: 780px) {
.site-header {
align-items: flex-start;
flex-direction: column;
}
.layout {
grid-template-columns: 1fr;
}
#map {
min-height: 60vh;
}
}
You closed the missing-backend gap. The map now turns six hosted inventory records into colored parcels with details that visitors can inspect.
Secret mission
Filter the Map by Status
Your map shows every parcel at once. Add a status control that lets visitors focus on Available, Reserved, or Sold lots while keeping the complete enriched inventory in browser memory.
Clean Up Your Resources
Clean Up Your Resources
Your Supabase Free Plan costs $0 per month. Decide whether to keep the project active, pause it for later, or delete its cloud and local resources.
Resources you used:
- Local subdivision-lot-map folder in Visual Studio Code. This contains your project files plus the Python virtual environment at .venv.
- Local .env file containing the project URL plus publishable key.
- Supabase Free Plan project containing the six-row public.lots table.
- Running Uvicorn process serving the local FastAPI application.
Keep everything running
No action needed. Choose this if you are still testing the map or updating the inventory.
- Uvicorn continues serving the map from your open VS Code workspace.
- Your Supabase project keeps the public.lots inventory available online.
- Supabase can pause Free Plan projects after a 7-day period of low activity.
- Your project files remain available inside subdivision-lot-map.
Pause - I'll come back to this later
Shut down the local server to free the terminal. Pause the Supabase project while keeping its data available for a later return.
- Return to the PowerShell terminal running Uvicorn in VS Code.
- Press Ctrl+C to stop the local server.
- Return to your project in the Supabase Dashboard.
- Select Settings in the project sidebar.
- Select General.
- Find Project availability.
- Select Pause Project.
- Confirm that the project shows a paused state.
Your local files remain unchanged. The hosted inventory stays attached to the paused project.
- Return to the paused project in the Supabase Dashboard when you want to continue.
- Select Resume project.
- Return to the PowerShell terminal in your subdivision-lot-map workspace.
- Restart the local application by running this command:
uvicorn main:app --reload
What does this command do?
- uvicorn starts the local web server.
- main:app points the server to the app object inside main.py.
- --reload restarts the development server when a project file changes.
Local Server Not Restarting?
Check that the terminal is inside subdivision-lot-map. Confirm that the terminal prompt shows the active .venv environment.
Help me restart the FastAPI app.
- Open http://127.0.0.1:8000 in your browser.
You will see the subdivision map load with the live inventory from the resumed Supabase project.
Delete - I don't want to use this again
Deleting both copies is final, so take a moment to make sure you no longer need the inventory or local code. Supabase permanently removes the project data plus its backups.
- Return to your project in the Supabase Dashboard.
- Select Settings in the project sidebar.
- Select General.
- Find Delete project.
- Select Delete Project.
- Enter the project name exactly.
- Confirm the deletion with Delete.
- Confirm that the project no longer appears in the Supabase Dashboard.
The online public.lots table is now removed. Finish by deleting the local workspace from its current location.
- Return to the PowerShell terminal running Uvicorn in VS Code.
- Press Ctrl+C to stop the local server.
- Close VS Code.
The local folder contains the .env credentials plus every project file. Removing the folder clears that complete local copy.
- Press the Windows key to open search.
- Type File Explorer in the search field.
- Press Enter to open File Explorer.
- Browse to the location containing subdivision-lot-map.
- Select the subdivision-lot-map folder.
- Press Shift+Delete to remove the folder permanently.
- Confirm the permanent deletion prompt.
- Confirm that subdivision-lot-map no longer appears in that location.
Nice Work!
Nice Work!
You did it! Your Supabase inventory now flows through FastAPI into an interactive MapLibre GL JS parcel map.
You've learned how to:
- Create a persistent Supabase lot inventory with public read-only access enforced by explicit grants plus Row Level Security.
- Expose online inventory through a FastAPI REST API that keeps database access behind the Python backend.
- Power status colors plus clickable lot details with a client-side data join from online inventory to GeoJSON parcel shapes.
- Secret Mission: Use the status filter to show a single inventory state from browser memory without another database request.
Ready to quiz yourself?