Offendersearch
Tutorial · developers

How to build a sex offender map with MapLibre

A working map in three small files: clusters when zoomed out, pins closer in, names close up, and a panel that opens the full record. It runs in test mode on made-up people, so you can build all of it before paying anything.

October 12, 2026 · The API behind it is the sex offender map API.

The plan

Three files

You draw the map. The API tells it what to draw for each view. Your map, our data: clusters when zoomed out, pins closer in, names close up, and the full record when one is opened.

FileWhat it does
server.jsThree routes on your own server. They add the secret key and pass each request on.
index.htmlThe map, a side panel, and a line for the notice.
main.jsThe map itself: layers, the request for each view, and what each click does.
Step 1

Get a key and make one test-mode request

Test mode returns made-up people in the real response shape. Add sandbox=true to any map request. It needs no plan and is never charged.

npm create vite@latest sample-map -- --template vanilla
cd sample-map
npm install maplibre-gl express
export OFFENDERSEARCH_KEY="your_key_here"

Create an account and copy the key from the dashboard. Then ask for one view. The box is west, south, east, north, in degrees.

curl -G https://api.offendersearch.app/v1/map/viewport \
  -H "X-API-Key: $OFFENDERSEARCH_KEY" \
  -d bbox=-87.70,41.83,-87.60,41.91 \
  -d zoom=12 \
  -d format=geojson \
  -d sandbox=true

The answer is a GeoJSON FeatureCollection. At that zoom it holds clusters. Every label in test mode starts with “Sample”. The property names are listed on the sex offender GeoJSON page.

Step 2

Keep the key on your server

The page asks your server. Your server adds the key and passes the request on. The key never reaches a browser.

import express from 'express';

const API = 'https://api.offendersearch.app/v1/map';
const KEY = process.env.OFFENDERSEARCH_KEY;
const app = express();

async function forward(path, query, res) {
  const url = new URL(API + path);
  for (const [name, value] of Object.entries(query)) {
    url.searchParams.set(name, String(value));
  }
  // Test mode: made-up people, never charged.
  // Remove this line when you go live.
  url.searchParams.set('sandbox', 'true');

  const upstream = await fetch(url, {
    headers: { 'X-API-Key': KEY },
  });
  res.status(upstream.status);
  res.type(upstream.headers.get('content-type') ?? 'json');
  res.send(await upstream.text());
}

app.get('/api/map/viewport', (req, res) =>
  forward('/viewport', req.query, res));

app.get('/api/map/groups/:id', (req, res) =>
  forward('/groups/' + encodeURIComponent(req.params.id), req.query, res));

app.get('/api/map/records/:id', (req, res) =>
  forward('/records/' + encodeURIComponent(req.params.id), {}, res));

app.listen(3001, () => console.log('Map routes on :3001'));

Three routes are all a map needs: one for a view, one for the people behind a shared pin, one for a record. In development, tell Vite to send /api to this server.

export default {
  server: {
    proxy: { '/api': 'http://localhost:3001' },
  },
};
Step 3

Make the page and start the map

A map on the left and a panel on the right. Replace the index.html Vite made with this one.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Sample map</title>
    <style>
      body { margin: 0; font: 14px/1.4 system-ui, sans-serif; }
      #map { position: absolute; inset: 0 320px 0 0; }
      #side {
        position: absolute; top: 0; right: 0; bottom: 0; width: 320px;
        box-sizing: border-box; padding: 16px; overflow: auto;
        border-left: 1px solid #ddd;
      }
      #notice { color: #555; font-size: 12px; }
      button { display: block; margin: 6px 0; }
    </style>
  </head>
  <body>
    <div id="map"></div>
    <aside id="side">
      <p id="total"></p>
      <p id="notice"></p>
      <div id="panel"></div>
    </aside>
    <script type="module" src="/main.js"></script>
  </body>
</html>

Now main.js, saved beside index.html. Start the map over Chicago, one of the cities test mode fills with sample people. The base map here is a free public style; use your own in production.

import { Map, Popup } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

const panel = document.getElementById('panel');
const FONT = ['Noto Sans Regular'];

const map = new Map({
  container: 'map',
  style: 'https://tiles.openfreemap.org/styles/liberty',
  center: [-87.6298, 41.8781], // Chicago: test mode has people here
  zoom: 10,
});
Step 4

Add one source and six layers

One GeoJSON source holds whatever the last view returned. The layers pick out clusters, areas, pins, counts and names by their properties.

map.on('load', () => {
  map.addSource('people', {
    type: 'geojson',
    data: { type: 'FeatureCollection', features: [] },
  });

  // Zoomed out: one circle per cluster, with its count.
  map.addLayer({
    id: 'clusters',
    type: 'circle',
    source: 'people',
    filter: ['has', 'point_count'],
    paint: {
      'circle-color': '#1d4ed8',
      'circle-opacity': 0.85,
      'circle-radius': ['step', ['get', 'point_count'], 16, 50, 22, 500, 30],
    },
  });
  map.addLayer({
    id: 'cluster-count',
    type: 'symbol',
    source: 'people',
    filter: ['has', 'point_count'],
    layout: {
      'text-field': ['get', 'point_count_abbreviated'],
      'text-font': FONT,
      'text-size': 12,
    },
    paint: { 'text-color': '#ffffff' },
  });

  // Approximate areas: a hollow ring, never an exact pin.
  map.addLayer({
    id: 'areas',
    type: 'circle',
    source: 'people',
    filter: ['==', ['get', 'kind'], 'area'],
    paint: {
      'circle-color': 'rgba(0, 0, 0, 0)',
      'circle-radius': 14,
      'circle-stroke-color': '#b45309',
      'circle-stroke-width': 2,
    },
  });

  // Pins: one person, or several people at one address.
  map.addLayer({
    id: 'pins',
    type: 'circle',
    source: 'people',
    filter: ['any',
      ['==', ['get', 'kind'], 'marker'],
      ['==', ['get', 'kind'], 'group']],
    paint: {
      'circle-color': '#b91c1c',
      'circle-radius': ['case', ['==', ['get', 'kind'], 'group'], 11, 6],
      'circle-stroke-color': '#ffffff',
      'circle-stroke-width': 1.5,
    },
  });

  // The count on a group or an area.
  map.addLayer({
    id: 'counts',
    type: 'symbol',
    source: 'people',
    filter: ['all', ['!', ['has', 'point_count']], ['has', 'count']],
    layout: {
      'text-field': ['to-string', ['get', 'count']],
      'text-font': FONT,
      'text-size': 11,
    },
    paint: {
      'text-color': ['case', ['==', ['get', 'kind'], 'area'],
        '#b45309', '#ffffff'],
    },
  });

  // Names under pins. They arrive from zoom 15.
  map.addLayer({
    id: 'names',
    type: 'symbol',
    source: 'people',
    filter: ['all', ['==', ['get', 'kind'], 'marker'], ['has', 'label']],
    layout: {
      'text-field': ['get', 'label'],
      'text-font': FONT,
      'text-size': 12,
      'text-anchor': 'top',
      'text-offset': [0, 0.9],
    },
    paint: {
      'text-color': '#111827',
      'text-halo-color': '#ffffff',
      'text-halo-width': 1.5,
    },
  });

  refresh();
});
  • Do not turn on the library’s clustering. The features arrive already clustered for the zoom you sent.
  • Draw areas differently. An area stands for people placed only to a ZIP code, city or county. A ring says “somewhere in here”; a solid pin would claim an address.
  • A larger pin is a shared address. Several people registered at one place are one pin with a count, and they never split apart.
Step 5

Ask for each view when the map settles

Send the box on screen and the zoom. The zoom decides the detail: clusters below 13, pins from 13, names from 15.

let request;
let timer;

// Ask once the map has settled, not on every frame of a drag.
map.on('moveend', () => {
  clearTimeout(timer);
  timer = setTimeout(refresh, 250);
});

async function refresh() {
  request?.abort();
  request = new AbortController();

  const b = map.getBounds();
  const query = new URLSearchParams({
    bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].join(','),
    zoom: String(Math.floor(map.getZoom())),
    format: 'geojson',
  });

  let res;
  try {
    res = await fetch('/api/map/viewport?' + query, {
      signal: request.signal,
    });
  } catch {
    return; // a newer request replaced this one
  }
  if (!res.ok) return;

  const data = await res.json();
  map.getSource('people').setData(data);
  document.getElementById('total').textContent =
    data.meta.total + ' in view';
  document.getElementById('notice').textContent = data.meta.notice;
}

Two details keep the map cheap and correct. The short wait means a drag is one request, not thirty. Cancelling the request in flight means an old answer can never overwrite a newer one. Each view is one map request, whatever is in it.

Step 6

Open a cluster on click

A cluster carries the box of the people in it and the zoom at which it comes apart. A click needs no request of its own.

map.on('click', 'clusters', (e) => {
  const p = e.features[0].properties;
  // MapLibre hands array properties back as JSON text.
  const [west, south, east, north] = JSON.parse(p.bbox);
  const cam = map.cameraForBounds([[west, south], [east, north]], {
    padding: 48,
  });
  map.easeTo({
    center: cam.center,
    zoom: Math.max(p.expansion_zoom, Math.min(cam.zoom, 17)),
  });
});

The map moves, moveend fires, and step 5 asks for the new view. Repeat and the clusters become pins, then named pins.

Step 7

Handle pins, shared addresses and areas

A named pin already holds what a popup shows. Only a pin that stands for several people needs a request, to list them.

function button(text, onClick) {
  const el = document.createElement('button');
  el.textContent = text;
  el.addEventListener('click', onClick);
  return el;
}

map.on('click', 'pins', (e) => {
  const feature = e.features[0];
  const p = feature.properties;
  const at = feature.geometry.coordinates;

  if (p.kind === 'group') {
    showGroup(p.groupId, p.count + ' people at this address');
    return;
  }
  if (!p.label) {
    // No name at this zoom. Go closer and the next view has one.
    map.easeTo({ center: at, zoom: 15 });
    return;
  }

  // Everything the popup shows is already on the pin: no request.
  const box = document.createElement('div');
  const name = document.createElement('strong');
  name.textContent = p.label;
  const housing = document.createElement('div');
  housing.textContent = 'Housing: ' + (p.housingStatus ?? 'not stated');
  box.append(name, housing, button('View record', () => openRecord(p.id)));
  new Popup({ offset: 10 }).setLngLat(at).setDOMContent(box).addTo(map);
});

map.on('click', 'areas', (e) => {
  const p = e.features[0].properties;
  showGroup(p.groupId, p.count + ' people placed only to ' + p.label);
});

async function showGroup(groupId, heading) {
  const res = await fetch('/api/map/groups/' + groupId);
  if (!res.ok) return;
  const group = await res.json();

  const title = document.createElement('h3');
  title.textContent = heading;
  panel.replaceChildren(
    title,
    ...group.members.map((m) =>
      button(m.label ?? 'View record', () => openRecord(m.id))),
  );
}

The text goes in with textContent, never as HTML, so a name can never run as markup. The list from the groups route is paged; this script shows the first page.

Step 8

Open the full record

A record open is the complete record behind one marker. Opening the same marker again is free. A new map request returns new marker ids, so opening that person from the new result is a new charge.

async function openRecord(id) {
  const res = await fetch('/api/map/records/' + id);
  if (!res.ok) return;
  const { record, recordRef, notice } = await res.json();

  const name = document.createElement('h3');
  name.textContent = record.name.full;

  const address = record.addresses?.[0];
  const where = document.createElement('p');
  where.textContent = address
    ? [address.line1, address.city, address.state].filter(Boolean).join(', ')
    : 'No address published';

  const small = document.createElement('p');
  small.textContent = notice;

  panel.replaceChildren(name, where, small);

  // recordRef opens this record again later, from a saved link.
  history.replaceState(null, '', '#record=' + recordRef);
}

// A saved link: open the record it names.
const saved = new URLSearchParams(location.hash.slice(1)).get('record');
if (saved) openRecord(saved);

The record is the same object the sex offender search API returns, so a panel you already have for search results works here too. Marker ids last 24 hours. For anything longer, such as a bookmark, keep the recordRef.

Step 9

Run it

node server.js &
npm run dev

Open the address Vite prints. Click a cluster, keep going until names appear, click a pin, then “View record”. The line under the count is the notice the API sends with every answer: “A pin shows a registered address, not a person's current location.” Keep it on screen.

Full code

The whole map, file by file

Everything above, in one place to copy.

import { Map, Popup } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

const panel = document.getElementById('panel');
const FONT = ['Noto Sans Regular'];

const map = new Map({
  container: 'map',
  style: 'https://tiles.openfreemap.org/styles/liberty',
  center: [-87.6298, 41.8781], // Chicago: test mode has people here
  zoom: 10,
});

map.on('load', () => {
  map.addSource('people', {
    type: 'geojson',
    data: { type: 'FeatureCollection', features: [] },
  });

  // Zoomed out: one circle per cluster, with its count.
  map.addLayer({
    id: 'clusters',
    type: 'circle',
    source: 'people',
    filter: ['has', 'point_count'],
    paint: {
      'circle-color': '#1d4ed8',
      'circle-opacity': 0.85,
      'circle-radius': ['step', ['get', 'point_count'], 16, 50, 22, 500, 30],
    },
  });
  map.addLayer({
    id: 'cluster-count',
    type: 'symbol',
    source: 'people',
    filter: ['has', 'point_count'],
    layout: {
      'text-field': ['get', 'point_count_abbreviated'],
      'text-font': FONT,
      'text-size': 12,
    },
    paint: { 'text-color': '#ffffff' },
  });

  // Approximate areas: a hollow ring, never an exact pin.
  map.addLayer({
    id: 'areas',
    type: 'circle',
    source: 'people',
    filter: ['==', ['get', 'kind'], 'area'],
    paint: {
      'circle-color': 'rgba(0, 0, 0, 0)',
      'circle-radius': 14,
      'circle-stroke-color': '#b45309',
      'circle-stroke-width': 2,
    },
  });

  // Pins: one person, or several people at one address.
  map.addLayer({
    id: 'pins',
    type: 'circle',
    source: 'people',
    filter: ['any',
      ['==', ['get', 'kind'], 'marker'],
      ['==', ['get', 'kind'], 'group']],
    paint: {
      'circle-color': '#b91c1c',
      'circle-radius': ['case', ['==', ['get', 'kind'], 'group'], 11, 6],
      'circle-stroke-color': '#ffffff',
      'circle-stroke-width': 1.5,
    },
  });

  // The count on a group or an area.
  map.addLayer({
    id: 'counts',
    type: 'symbol',
    source: 'people',
    filter: ['all', ['!', ['has', 'point_count']], ['has', 'count']],
    layout: {
      'text-field': ['to-string', ['get', 'count']],
      'text-font': FONT,
      'text-size': 11,
    },
    paint: {
      'text-color': ['case', ['==', ['get', 'kind'], 'area'],
        '#b45309', '#ffffff'],
    },
  });

  // Names under pins. They arrive from zoom 15.
  map.addLayer({
    id: 'names',
    type: 'symbol',
    source: 'people',
    filter: ['all', ['==', ['get', 'kind'], 'marker'], ['has', 'label']],
    layout: {
      'text-field': ['get', 'label'],
      'text-font': FONT,
      'text-size': 12,
      'text-anchor': 'top',
      'text-offset': [0, 0.9],
    },
    paint: {
      'text-color': '#111827',
      'text-halo-color': '#ffffff',
      'text-halo-width': 1.5,
    },
  });

  refresh();
});

let request;
let timer;

// Ask once the map has settled, not on every frame of a drag.
map.on('moveend', () => {
  clearTimeout(timer);
  timer = setTimeout(refresh, 250);
});

async function refresh() {
  request?.abort();
  request = new AbortController();

  const b = map.getBounds();
  const query = new URLSearchParams({
    bbox: [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].join(','),
    zoom: String(Math.floor(map.getZoom())),
    format: 'geojson',
  });

  let res;
  try {
    res = await fetch('/api/map/viewport?' + query, {
      signal: request.signal,
    });
  } catch {
    return; // a newer request replaced this one
  }
  if (!res.ok) return;

  const data = await res.json();
  map.getSource('people').setData(data);
  document.getElementById('total').textContent =
    data.meta.total + ' in view';
  document.getElementById('notice').textContent = data.meta.notice;
}

map.on('click', 'clusters', (e) => {
  const p = e.features[0].properties;
  // MapLibre hands array properties back as JSON text.
  const [west, south, east, north] = JSON.parse(p.bbox);
  const cam = map.cameraForBounds([[west, south], [east, north]], {
    padding: 48,
  });
  map.easeTo({
    center: cam.center,
    zoom: Math.max(p.expansion_zoom, Math.min(cam.zoom, 17)),
  });
});

function button(text, onClick) {
  const el = document.createElement('button');
  el.textContent = text;
  el.addEventListener('click', onClick);
  return el;
}

map.on('click', 'pins', (e) => {
  const feature = e.features[0];
  const p = feature.properties;
  const at = feature.geometry.coordinates;

  if (p.kind === 'group') {
    showGroup(p.groupId, p.count + ' people at this address');
    return;
  }
  if (!p.label) {
    // No name at this zoom. Go closer and the next view has one.
    map.easeTo({ center: at, zoom: 15 });
    return;
  }

  // Everything the popup shows is already on the pin: no request.
  const box = document.createElement('div');
  const name = document.createElement('strong');
  name.textContent = p.label;
  const housing = document.createElement('div');
  housing.textContent = 'Housing: ' + (p.housingStatus ?? 'not stated');
  box.append(name, housing, button('View record', () => openRecord(p.id)));
  new Popup({ offset: 10 }).setLngLat(at).setDOMContent(box).addTo(map);
});

map.on('click', 'areas', (e) => {
  const p = e.features[0].properties;
  showGroup(p.groupId, p.count + ' people placed only to ' + p.label);
});

async function showGroup(groupId, heading) {
  const res = await fetch('/api/map/groups/' + groupId);
  if (!res.ok) return;
  const group = await res.json();

  const title = document.createElement('h3');
  title.textContent = heading;
  panel.replaceChildren(
    title,
    ...group.members.map((m) =>
      button(m.label ?? 'View record', () => openRecord(m.id))),
  );
}

async function openRecord(id) {
  const res = await fetch('/api/map/records/' + id);
  if (!res.ok) return;
  const { record, recordRef, notice } = await res.json();

  const name = document.createElement('h3');
  name.textContent = record.name.full;

  const address = record.addresses?.[0];
  const where = document.createElement('p');
  where.textContent = address
    ? [address.line1, address.city, address.state].filter(Boolean).join(', ')
    : 'No address published';

  const small = document.createElement('p');
  small.textContent = notice;

  panel.replaceChildren(name, where, small);

  // recordRef opens this record again later, from a saved link.
  history.replaceState(null, '', '#record=' + recordRef);
}

// A saved link: open the record it names.
const saved = new URLSearchParams(location.hash.slice(1)).get('record');
if (saved) openRecord(saved);
Going live

From sample people to real records

The code does not change. Four things do.

  1. Turn the Map API on in the dashboard.
  2. Remove the sandbox line from server.js.
  3. Put your own sign-in in front of the three routes, so only your users reach them.
  4. Keep the notice on screen wherever the map is shown.

$2.00 per 1,000 map requests and $0.015 per record opened, with a $300 monthly minimum applied toward usage. What that means month by month is worked out on the sex offender map API page.

From here: a radius around an address, a count badge, filters and drawn areas all use the same route with different parameters. Want users to hear about changes near a place? That is sex offender monitoring.

Questions

Building the map: common questions

Do I need to pay to follow this tutorial?

No. Test mode returns made-up people in the real response shape. Add sandbox=true to any map request. It needs no plan and is never charged.

Why does the map ask a server of mine and not the API directly?

The secret key must not be in page code, where anyone can read it. Your server holds the key and adds it to each request. If you want the browser to call the API directly, create a map browser key and list the sites allowed to use it.

Can I use Mapbox GL, Leaflet or Google Maps instead of MapLibre?

Yes. Mapbox GL takes this code almost unchanged. Leaflet and Google Maps both accept GeoJSON, or you can ask for the plain JSON format and place markers yourself. The docs have a complete map for each.

Why are there no names until I zoom in?

Pins start at zoom 13 and carry a name from zoom 15. Before that the map shows positions and counts only, so the script zooms in when a pin without a name is clicked.

Which registries will the live map show?

58 US registries: the 50 states, DC and the US territories. The map shows continuously updated registry data, and each record opened carries its own lastCheckedAt.

Is a record opened from the map a consumer report?

No. Standard results are not a consumer report and must not be used for FCRA-governed hiring, tenancy or credit decisions. Those run through FCRA Partner Access.

Standard Offendersearch results are not a consumer report.

Build the map today

Test mode is free. Turn the Map API on when the map is ready for real records.