How to Auto-Complete Dutch Business Addresses with KVK Data
· KVKBase Team

How to Auto-Complete Dutch Business Addresses with KVK Data

Build a business address autocomplete using KVK data. Covers debounced search, dropdown UI, field population, and combining with postal code APIs.

autocompleteaddressesfrontend

How to Auto-Complete Dutch Business Addresses with KVK Data

Address forms are tedious. For B2B applications, they are even worse — your users need to enter a company name, find the correct registered address, and make sure everything matches the official records. A business address autocomplete eliminates most of that friction.

In this guide, we will build an autocomplete that searches for Dutch companies by name and fills in the full address from KVK data.

The Approach

The autocomplete works in three steps:

  1. The user starts typing a company name
  2. A debounced search query hits the KVK data API
  3. Results appear in a dropdown; selecting one fills the address fields

Let us build each piece.

Step 1: The HTML Structure

Start with a clean form layout:

<form id="business-form">
  <div class="autocomplete-wrapper">
    <label for="company-search">Company name</label>
    <input
      type="text"
      id="company-search"
      placeholder="Start typing a company name..."
      autocomplete="off"
      role="combobox"
      aria-expanded="false"
      aria-controls="search-results"
      aria-autocomplete="list"
    >
    <ul id="search-results" role="listbox" class="results-dropdown hidden"></ul>
  </div>

  <div class="address-fields">
    <div class="field-row">
      <div class="field">
        <label for="kvk-number">KVK number</label>
        <input type="text" id="kvk-number" readonly>
      </div>
    </div>

    <div class="field-row">
      <div class="field">
        <label for="street">Street</label>
        <input type="text" id="street" readonly>
      </div>
      <div class="field field-small">
        <label for="house-number">Number</label>
        <input type="text" id="house-number" readonly>
      </div>
    </div>

    <div class="field-row">
      <div class="field field-small">
        <label for="postal-code">Postal code</label>
        <input type="text" id="postal-code" readonly>
      </div>
      <div class="field">
        <label for="city">City</label>
        <input type="text" id="city" readonly>
      </div>
    </div>
  </div>
</form>

You do not want to fire an API request on every keystroke. A debounce function waits until the user pauses typing:

function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const searchInput = document.getElementById('company-search');
const resultsList = document.getElementById('search-results');

const handleSearch = debounce(async (query) => {
  if (query.length < 2) {
    hideResults();
    return;
  }

  try {
    const companies = await searchCompanies(query);
    renderResults(companies);
  } catch (error) {
    console.error('Search failed:', error);
    hideResults();
  }
}, 300);

searchInput.addEventListener('input', (e) => {
  handleSearch(e.target.value.trim());
});

The searchCompanies function calls your backend, which proxies the request to the KVKBase API:

async function searchCompanies(query) {
  const response = await fetch(
    `/api/companies/search?q=${encodeURIComponent(query)}&limit=8`
  );

  if (!response.ok) {
    throw new Error(`Search error: ${response.status}`);
  }

  return response.json();
}

On the server side:

app.get('/api/companies/search', async (req, res) => {
  const { q, limit = 8 } = req.query;

  if (!q || q.length < 2) {
    return res.json([]);
  }

  const response = await fetch(
    `https://api.kvkbase.nl/api/v1/search?q=${encodeURIComponent(q)}&limit=${limit}`,
    { headers: { 'Authorization': `Bearer ${process.env.KVKBASE_API_KEY}` } }
  );

  const data = await response.json();
  res.json(data);
});

Step 3: The Dropdown UI

Render search results in a clean dropdown:

function renderResults(companies) {
  if (companies.length === 0) {
    resultsList.innerHTML = '<li class="no-results">No companies found</li>';
    showResults();
    return;
  }

  resultsList.innerHTML = companies.map((company, index) => `
    <li
      role="option"
      id="result-${index}"
      class="result-item"
      data-kvk="${company.kvkNumber}"
      tabindex="-1"
    >
      <span class="company-name">${escapeHtml(company.tradeName)}</span>
      <span class="company-details">
        ${escapeHtml(company.address?.city || '')} - KVK ${company.kvkNumber}
      </span>
    </li>
  `).join('');

  // Attach click handlers
  resultsList.querySelectorAll('.result-item').forEach(item => {
    item.addEventListener('click', () => {
      const kvkNumber = item.dataset.kvk;
      selectCompany(companies.find(c => c.kvkNumber === kvkNumber));
    });
  });

  showResults();
}

function showResults() {
  resultsList.classList.remove('hidden');
  searchInput.setAttribute('aria-expanded', 'true');
}

function hideResults() {
  resultsList.classList.add('hidden');
  searchInput.setAttribute('aria-expanded', 'false');
}

function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

Step 4: Selecting and Filling Address Fields

When the user selects a company, fill in all the address fields:

async function selectCompany(company) {
  // Update the search input with the company name
  searchInput.value = company.tradeName;
  hideResults();

  // Fill address fields
  document.getElementById('kvk-number').value = company.kvkNumber;
  document.getElementById('street').value = company.address?.street || '';
  document.getElementById('house-number').value = company.address?.houseNumber || '';
  document.getElementById('postal-code').value = company.address?.postalCode || '';
  document.getElementById('city').value = company.address?.city || '';

  // Remove readonly so users can edit if needed
  document.querySelectorAll('.address-fields input[readonly]').forEach(input => {
    input.removeAttribute('readonly');
  });
}

Step 5: Keyboard Navigation

A good autocomplete supports full keyboard navigation:

let activeIndex = -1;

searchInput.addEventListener('keydown', (e) => {
  const items = resultsList.querySelectorAll('.result-item');
  if (items.length === 0) return;

  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault();
      activeIndex = Math.min(activeIndex + 1, items.length - 1);
      updateActiveItem(items);
      break;

    case 'ArrowUp':
      e.preventDefault();
      activeIndex = Math.max(activeIndex - 1, 0);
      updateActiveItem(items);
      break;

    case 'Enter':
      e.preventDefault();
      if (activeIndex >= 0 && items[activeIndex]) {
        items[activeIndex].click();
      }
      break;

    case 'Escape':
      hideResults();
      activeIndex = -1;
      break;
  }
});

function updateActiveItem(items) {
  items.forEach((item, i) => {
    if (i === activeIndex) {
      item.classList.add('active');
      item.scrollIntoView({ block: 'nearest' });
      searchInput.setAttribute('aria-activedescendant', item.id);
    } else {
      item.classList.remove('active');
    }
  });
}

Step 6: Styling

Clean CSS for the autocomplete:

.autocomplete-wrapper {
  position: relative;
  max-width: 500px;
}

.results-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  background: white;
  border: 1px solid #d1d5db;
  border-top: none;
  border-radius: 0 0 8px 8px;
  max-height: 300px;
  overflow-y: auto;
  z-index: 100;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

.results-dropdown.hidden {
  display: none;
}

.result-item {
  padding: 12px 16px;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 2px;
  border-bottom: 1px solid #f3f4f6;
}

.result-item:hover,
.result-item.active {
  background: #f0f7ff;
}

.company-name {
  font-weight: 500;
  color: #111827;
}

.company-details {
  font-size: 13px;
  color: #6b7280;
}

.no-results {
  padding: 12px 16px;
  color: #6b7280;
  font-style: italic;
}

.field-row {
  display: flex;
  gap: 12px;
  margin-bottom: 12px;
}

.field {
  flex: 1;
}

.field-small {
  flex: 0 0 120px;
}

Combining with a Postal Code API

For residential addresses or when users want to enter a different address than the registered one, you can combine KVK data with a Dutch postal code API:

async function lookupByPostalCode(postalCode, houseNumber) {
  const response = await fetch(
    `/api/address?postalCode=${postalCode}&houseNumber=${houseNumber}`
  );
  return response.json();
}

// Let the user switch between KVK address and manual entry
document.getElementById('manual-address-toggle').addEventListener('click', () => {
  // Show postal code + house number fields
  document.getElementById('manual-entry').classList.remove('hidden');

  // Make address fields editable
  document.querySelectorAll('.address-fields input').forEach(input => {
    input.removeAttribute('readonly');
    input.value = '';
  });
});

This gives users flexibility: use the registered KVK address for most cases, or enter a different shipping or billing address when needed.

Performance Tips

A few optimizations to keep the autocomplete fast and efficient:

  1. Debounce at 300ms: fast enough to feel responsive, slow enough to avoid excessive API calls
  2. Minimum 2 characters: do not search on a single letter — the results would be too broad
  3. Limit results to 8: more results mean a longer dropdown that is harder to scan
  4. Cache recent searches: if the user types “Bol”, deletes, and types “Bol” again, serve from cache
  5. Cancel previous requests: if a new keystroke arrives before the previous request completes, cancel the old one
let currentController = null;

async function searchCompanies(query) {
  // Cancel previous request
  if (currentController) {
    currentController.abort();
  }

  currentController = new AbortController();

  const response = await fetch(
    `/api/companies/search?q=${encodeURIComponent(query)}&limit=8`,
    { signal: currentController.signal }
  );

  return response.json();
}

Summary

A business address autocomplete built on KVK data eliminates manual entry, reduces errors, and gives your application a polished feel. With KVKBase, the search API does the heavy lifting — you just need to build the UI layer on top.

The combination of debounced search, keyboard navigation, and clean dropdown styling creates an experience that feels native to the browser, while pulling from an authoritative data source.