fl-parcel-lookup
Package @27colab/fl-parcel-lookup · Source on GitHub · MIT · Node 18+ · ESM · zero runtime dependencies
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”.
Install
Section titled “Install”npm install @27colab/fl-parcel-lookupBring your own keys
Section titled “Bring your own keys”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.
Run it without a network
Section titled “Run it without a network”normalizeAddress is pure. The second half injects a stub fetch shaped like a county response (invented data), so the whole lookup path runs offline.
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);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: falseNotice that each county gets its own preferred spelling first (7 for Miami-Dade, 7TH for Palm Beach) and the alternate second.
Against the real services
Section titled “Against the real services”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).
// 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;}API overview
Section titled “API overview”lookupByAddress(input, options?)→ParcelRecord | null.inputis a Google place (address_componentsor the neweraddressComponents) 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 plussearch, the street lines per county. ThrowsInvalidAddressErrororUnsupportedCountyError.createClient(defaults):fetch,timeoutMs,retries,signal,userAgent,includeOwner,includeLegal,zip,unitFallback,maxResults,providers.detectCounty,detectCountyFromFolio,formatFolio.
How to read the result:
| Situation | Result |
|---|---|
| One parcel matches | ambiguous: false |
| Condo building, no unit given | first unit, ambiguous: true, candidates: n; use searchByAddress for all |
| Unit given and found | unitMatched: true |
| Unit given, not found | null, or with { unitFallback: true } the building-level match with unitMatched: false |
| Nothing | null |
Errors extend ParcelLookupError with a code and a retryable flag: InvalidAddressError, InvalidFolioError, UnsupportedCountyError, TimeoutError, NetworkError, HttpError, ServiceError, BadResponseError, AbortedError.
Privacy
Section titled “Privacy”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.
Limits
Section titled “Limits”- Endpoint drift is the main maintenance risk. Counties re-publish and rename services without notice. The package dates its endpoints in
docs/endpoints.mdand 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
nullthere 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 ofAvenue of the …-style names, intersections or PO boxes. - Matching is a prefix
LIKEplus an exact client-side check, so1 NW 7 STdoes not match11 NW 7 ST. - Issues are triaged when time allows.