← Blog
🏫

Building School Search over 215,000 Records

Case Study · 5 min read

A school directory, an admissions app, or a facility map — all of them rest on the same query pattern.

Three reference datasets are available through the same API: 215,333 schools (from Dapodik), 4,759 higher-education institutions (PDDikti), and 3,116 hospitals (SIRS Kemenkes). The pattern is identical, so what you learn on one dataset transfers directly to the other two.

Filter, do not scroll

With 215,000 rows, showing "all schools" is meaningless. Design your UI around filters from the start — and the API provides exactly the filters you need:

# private vocational schools in one regency
GET /schools?bentuk=SMK&status=S&regency=3204

# search by name
GET /schools?q=SMA Negeri 1

# direct lookup by NPSN
GET /schools?npsn=20106343

Filters combine freely, and every response carries total — that number drives your pagination and tells the user how broad their result set is.

The response shape

{
  "status": "success",
  "total": 749,
  "page": 1,
  "per_page": 100,
  "total_pages": 8,
  "count": 100,
  "source": "Dapodik/Kemdikbud",
  "data": [
    {
      "npsn": "20106343",
      "name": "SMA NEGERI 1 CIAWIGEBANG",
      "bentuk": "SMA",
      "status": "N",
      "province": "Jawa Barat",
      "regency": "Kab. Kuningan",
      "district": "Ciawigebang",
      "address": "Jl. Siliwangi No. 1",
      "lat": -6.974,
      "lng": 108.5894
    }
  ]
}

Coordinates unlock map features

Most school rows carry lat and lng, which turns a text directory into a map. Some rows are null — Dapodik data is not complete for every school — so always handle that case rather than assuming coordinates exist:

const res = await fetch('/api/proxy/schools?bentuk=SMA&regency=3204&per_page=500');
const { data } = await res.json();

data
  .filter((s) => s.lat !== null && s.lng !== null)   // skip rows without coords
  .forEach((s) => {
    L.marker([s.lat, s.lng])
      .bindPopup(`<strong>${s.name}</strong><br>${s.address ?? ''}`)
      .addTo(map);
  });

Paginate without making users wait

The per_page ceiling is 500. For long lists, fetch the first page immediately and load the rest in the background — the user sees results at once instead of staring at a spinner.

async function fetchAll(query) {
  const first = await get(`${query}&page=1&per_page=500`);
  render(first.data);                         // show immediately

  const rest = [];
  for (let p = 2; p <= first.total_pages; p++) {
    rest.push(get(`${query}&page=${p}&per_page=500`));
  }
  const pages = await Promise.all(rest);      // load in parallel
  pages.forEach((pg) => render(pg.data));
}

Universities and hospitals

The other two datasets use the same query pattern, with filters suited to their domain:

# state polytechnics
GET /universities?q=politeknik negeri

# psychiatric hospitals in West Java
GET /hospitals?type=RS Jiwa&province=32
💡 This data is a snapshot of government registries, not a real-time feed. For directories and search, that is exactly right. For decisions that hinge on today's accreditation status, verify against the official source.

Ready to build?

Create a free account and get your API key — 5,000 free hits a month, and two endpoints that need no key at all.