Skip to content

All posts

Build an AI front desk, part 5 · 5 min read

One street, three spellings, looking up Florida parcels without guessing

How a county GIS search that works for one county silently misses in another, and what it took to make an address lookup honest about what it found.

By Jesus Vasquez · Updated

Source code: github.com/27colab/fl-parcel-lookup · Documentation

On this page

Key takeaways

  • Parse the address into parts first, then write the street line the way each county stores it, likely spelling first, alternate second, and ask for both.
  • Use a prefix search, then check each row yourself. The database narrows the field; the code decides, and that is what keeps 7 ST from matching 7 STATION RD.
  • Say how sure you are. A condo building searched without a unit comes back as two folios and ambiguous, not a confident wrong answer.
  • Leave owner data out by default. A lookup that returns owners is a people search, and a property appraiser's record is a pre-fill, not title.

The first version of this lookup was fourteen lines inside a form handler, and it worked in my demo. You picked an address from Google's autocomplete, the form took the part before the first comma, stripped the ordinal suffixes, and searched the county's parcel layer for a match. Out came the folio number and the legal description, and the fields filled themselves in.

It worked because I tested it on Miami-Dade. Reading the code again later, I could see it would quietly fail on the county next door.

  • 100 NW 7th Street Apt 4B
  • 100 Northwest Seventh St, Miami
  • 100 N.W. 7 Street #4B

Parse into parts

number 100 · direction NW · name 7 · type ST · unit 4B

  • Miami-Dade (stores ordinals stripped)100 NW 7 ST or 100 NW 7TH ST
  • Palm Beach (stores ordinals kept)100 NW 7TH ST or 100 NW 7 ST
  • Broward (mixed: varies by street)100 NW 7TH ST or 100 NW 7 ST
All invented addresses. Each county stores the same street differently, so the first spelling is the best guess and the second is the fallback, and the query asks for both.

Take an invented address, 100 NW 7th Street. Miami-Dade stores that street as NW 7 ST, with the ordinal stripped. Palm Beach stores NW 7TH ST, with it kept. Broward does both, depending on the street, and some of its rows have trailing spaces. My handler stripped the ordinal for everyone, so for Palm Beach it searched for a string that does not exist in that county's data. No error, no warning. Just an empty result that the form treated as "enter it by hand."

I found two more problems in the same handler once I looked. It searched with a contains match, so a search for 1 NW 7 ST also matched 11 NW 7 ST. And it took the first result of whatever came back, so a condo building with two hundred units returned an arbitrary unit's folio, because the unit never reached the query. And it printed full responses to the server log while I debugged, which is not something I would repeat with property data.

Normalize into parts, then speak each county's dialect

The library starts by parsing an address into parts: number, direction, street name, street type, unit, city, ZIP. It accepts the shape Google's Places API returns, the newer Places format, or plain text. Then it writes out the street line the way each county stores it, with the likely spelling first and the alternate second, and it asks for both at once.

It also expands the forms people really type. Northwest becomes NW, N.W. becomes NW, Seventh becomes 7 and 7TH. A lone direction is a street name, so West Avenue stays West Avenue. State Road 7, SR-84 and US Highway 1 each produce every spelling, because I never verified how each county stores road designations, and guessing is the whole bug I was fixing.

A prefix search, then an exact check

The query uses a prefix match instead of contains, and then the library checks each row itself. The street line must equal the address, or be followed by a space; with a unit, the letters and digits after it must equal the unit. That is what stops 7 ST from matching 7 STATION RD, and it is the reason the client-side check exists at all. The database narrows the field; the code decides.

Results are de-duplicated by folio, because Miami-Dade returns one row per polygon part and Broward can return several address points for one parcel. The answer then says how sure it is: how many distinct parcels matched, whether the match is ambiguous, and whether the unit was matched. A condo building searched without a unit comes back as two folios and ambiguous: true, instead of a confident wrong answer.

Which county, without asking

County detection goes in order: an explicit option, then the county component of the Places result, then a county named in the text, then a hand-kept table of incorporated cities. A street named Broward is not mistaken for the county. An address outside the three supported counties raises a clear error instead of an empty result.

Broward takes two hops

  • Miami-Dadeaddressparcel layerfolio + legal description
  • Palm Beachaddressparcel layerfolio + legal description
  • Browardaddressaddress pointsfoliotax rolllegal: 14 joined linesno point for the building: null
Two counties answer in one request. Broward needs two: an address-points layer to find the folio, then the tax roll for the legal description, which comes back as fixed-width lines.

Miami-Dade and Palm Beach put the folio and the legal description in one layer. Broward does not. I look up the address in the address-points layer to find the folio, then ask the tax roll for the legal description, which arrives as fourteen fixed-width lines that I join with a space. A word that was split across two lines stays split. And address points do not cover every building, so a null can mean the county has no point for that building, not that the address is wrong.

Broward also returns a 403 to any request with no User-Agent, including curl's default. The library always sends one that names the project. Palm Beach has its own trap: its city and ZIP columns hold the owner's mailing address, not the property's, so they cannot be used as filters.

What I deliberately left out

  • Owner names are off by default and the owner fields are not even requested. There is no owner search and no legal-description search, because that turns a lookup into a people search.
  • Query values are limited to a small character set, with apostrophes doubled, so a pasted address cannot smuggle a wildcard or a quote into the query. Nothing is logged, and errors carry the service URL without the query.
  • The HTTP layer has a timeout, retries on timeouts and 5xx with backoff, honors Retry-After, and turns an HTTP 200 that carries an error body into a real error.

What I have and haven't proven

  • There are 170 offline tests: a large table of address cases, the providers against fixtures, error handling and county detection. All fixtures use invented addresses.
  • A live suite checks that each service still has the fields I read, and round-trips an address to a folio and back. It passed on the day I wrote it, before the last round of address fixes, and I have not re-run it since.
  • Fractional street numbers, intersections and PO boxes are not handled, and my table of cities is kept by hand. Broward's ordinal behavior is mixed, so I hedge with both spellings instead of modeling a rule I do not understand.
  • Only three counties are supported. Adding a fourth is a provider of about a hundred lines.
  • It is a pre-fill, not a source of truth. A property appraiser's record is not title; for anything legal, confirm against the recorded deed. Counties rename and republish these services without notice. And check each service's terms before heavy use. I am not affiliated with any county.

I built the library with Claude Code, from an idea that began as that form handler. I defined the failure cases, ran the live checks, and decided what to leave out.

What's in the library

An address normalizer, per-county providers for Miami-Dade, Broward and Palm Beach, county detection, and a lookup that reports how sure it is. The parser runs offline; the lookups call the counties' public services, which need no key.

import { normalizeAddress } from "@27colab/fl-parcel-lookup";

const n = normalizeAddress("100 Northwest 7th Street Apt 4B, Miami, FL 33125");
console.log(n.county, n.unit, n.search["miami-dade"], n.search["palm-beach"]);
// miami-dade 4B [ '100 NW 7 ST', '100 NW 7TH ST' ] [ '100 NW 7TH ST', '100 NW 7 ST' ]
From the repository quickstart (pure and offline).

You bring a Google Places key if you want autocomplete in front of it (the parser also takes plain text); a fourth county, if you need one; a read of each county service's terms before heavy use; and a recorded deed for anything legal.

Get it

MIT-licensed, early (v0.1), written with Claude Code and directed and reviewed by me.

git clone https://github.com/27colab/fl-parcel-lookup.git
cd fl-parcel-lookup && npm ci && npm test

Who wrote this

  • Jesus Vasquez · Engineering · fractional CTO

    Builds it: WordPress and Next.js, integrations and AI agents, with the tests, analytics and accessibility that keep it honest.

Sources

  1. github.com/27colab/fl-parcel-lookup
  2. docs.27colab.com/integrations/fl-parcel-lookup/
  3. theuxui.dev/open-source/fl-parcel-lookup/

Want this done for your company?

Tell us what’s going on. You get a written brief back, and both of us read it.

Talk to us about your platform

We’d like to measure how people use this site with Google Analytics, Microsoft Clarity, and our own visit log, and whether our ads lead to calls. Nothing loads unless you allow it. Privacy policy