Skip to content

fl-parcel-lookup

Look up a Florida parcel’s folio number and legal description from an address, for Miami-Dade, Broward and Palm Beach, using each county’s public ArcGIS services. The effort is not the HTTP call. Each county stores the same street address differently (NW 7 ST vs NW 7TH ST vs NW 58th AVE, units appended or not, a ZIP that is sometimes the owner’s mailing ZIP), so a Google Places address rarely matches as typed. This package turns a Places result into the spellings each county uses, queries safely, and tells you when the answer is ambiguous.

Not affiliated with any county or appraiser. Data is provided by them “as is”.

Terminal window
npm install @27colab/fl-parcel-lookup

None. The county services need no key or login, and no Google API key is used here: you get the Places result in your own app (browser or server) and pass it in. A plain comma-separated address string works too.

normalizeAddress is pure. The second half injects a stub fetch shaped like a county response (invented data), so the whole lookup path runs offline.

samples/src/parcel-lookup.ts
import { createClient, normalizeAddress, type Fetch } from '@27colab/fl-parcel-lookup';
// 1. Address normalisation is pure and offline.
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']);
// 2. A lookup against a stubbed county service (invented data, no network).
const stub: Fetch = async () =>
new Response(
JSON.stringify({
features: [
{
attributes: {
FOLIO: '0100000000010',
LEGAL: 'EXAMPLE PARK SUBD PB 1-1 LOT 5 BLK 2',
TRUE_SITE_ADDR: '100 NW 7 ST',
TRUE_SITE_ZIP_CODE: '33125-0000',
},
centroid: { x: -80.2, y: 25.78 },
},
],
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
const parcels = createClient({ fetch: stub });
const parcel = await parcels.lookupByAddress('100 NW 7th St, Miami, FL 33125');
console.log(parcel?.folio, parcel?.county, parcel?.legal, 'ambiguous:', parcel?.ambiguous);
output
miami-dade 4B [ '100 NW 7 ST', '100 NW 7TH ST' ] [ '100 NW 7TH ST', '100 NW 7 ST' ]
0100000000010 miami-dade EXAMPLE PARK SUBD PB 1-1 LOT 5 BLK 2 ambiguous: false

Notice that each county gets its own preferred spelling first (7 for Miami-Dade, 7TH for Palm Beach) and the alternate second.

Needs network access to the counties’ public services. Typechecked, not executed in CI by this docs repo (the package has its own opt-in live test suite).

samples/src/parcel-live.ts
// Typechecked, not run: hits the real county services.
import { lookupByAddress, lookupByFolio, searchByAddress, UnsupportedCountyError } from '@27colab/fl-parcel-lookup';
try {
const parcel = await lookupByAddress('100 NW 7th St Apt 4B, Miami, FL 33125');
if (parcel) console.log(parcel.folio, parcel.legal, parcel.source.url, parcel.unitMatched);
const all = await searchByAddress('100 NW 7th St, Miami, FL 33125'); // every unit of a condo
console.log(all.length);
console.log(await lookupByFolio('01-0000-000-0010')); // dashes accepted
} catch (e) {
if (e instanceof UnsupportedCountyError) console.log('Not Miami-Dade, Broward or Palm Beach');
else throw e;
}
  • lookupByAddress(input, options?) → ParcelRecord | null. input is a Google place (address_components or the newer addressComponents) or a string. The county is detected from the Places county component, then an explicit “X County”, then a table of well-known cities; pass { county } to skip detection.
  • lookupByFolio(folio, options?): dashes and spaces accepted; the county is inferred from the folio shape (13 digits Miami-Dade, 17 Palm Beach, 12 alphanumerics Broward) or passed explicitly.
  • searchByAddress(input, options?) → every distinct parcel on the street line (all units of a condo).
  • normalizeAddress(input, { county? }): parsed pieces plus search, the street lines per county. Throws InvalidAddressError or UnsupportedCountyError.
  • createClient(defaults): fetch, timeoutMs, retries, signal, userAgent, includeOwner, includeLegal, zip, unitFallback, maxResults, providers.
  • detectCounty, detectCountyFromFolio, formatFolio.

How to read the result:

SituationResult
One parcel matchesambiguous: false
Condo building, no unit givenfirst unit, ambiguous: true, candidates: n; use searchByAddress for all
Unit given and foundunitMatched: true
Unit given, not foundnull, or with { unitFallback: true } the building-level match with unitMatched: false
Nothingnull

Errors extend ParcelLookupError with a code and a retryable flag: InvalidAddressError, InvalidFolioError, UnsupportedCountyError, TimeoutError, NetworkError, HttpError, ServiceError, BadResponseError, AbortedError.

Owner names and mailing details are public record but are still personal data. Owner names are off by default: unless you pass includeOwner: true the owner fields are not requested. The library never logs, errors carry only the service URL without its query, mailing addresses are never read, and there is no search by owner. If you store owner names, retention and use are yours to decide.

  • Endpoint drift is the main maintenance risk. Counties re-publish and rename services without notice. The package dates its endpoints in docs/endpoints.md and has a weekly live check that fails when a field disappears, but a break can precede the fix.
  • Broward uses two services (address points for the folio, tax roll for legal and owner), and its address points do not cover every building, so null there can mean “no address point”.
  • Known address gaps: fractional numbers (100 1/2 Main St) are passed through unverified; city detection relies on a hand-kept table; no handling of Avenue of the …-style names, intersections or PO boxes.
  • Matching is a prefix LIKE plus an exact client-side check, so 1 NW 7 ST does not match 11 NW 7 ST.
  • Issues are triaged when time allows.