Docs
Display and explore

Location columns

Store places with coordinates, a name and an address, edit them with address suggestions from your geocoder, and filter by distance or area.

A location column holds a place: coordinates with an optional name and address. Cells show the place, the editor searches addresses through your geocoder, filters find records within a distance or an area, and the Map view shows the records on a map. React and Vue behave the same.

Values

A place is stored as an object:

type LocationValue = {
  lat: number; // latitude in degrees (WGS 84), -90 to 90
  lng: number; // longitude in degrees, -180 to 180
  label?: string; // short name, e.g. "Head office"
  address?: string; // postal address
};

The table also reads latitude/longitude and lon keys, GeoJSON points ({ type: "Point", coordinates: [lng, lat] }) and "lat, lng" text such as "48.8566, 2.3522", so existing data can be shown without a migration. The editor always saves the object above. A value without valid coordinates is not a place: a stored value that cannot be read fails the type check with "Expected a location (lat, lng)".

Declare a column

const columns = [
  { id: "name", header: "Name", type: "text" },
  { id: "site", header: "Site", type: "location", inlineEdit: true },
];

The column's type sets the cell, the inline editor, the generated record form field and the filter controls, as for other types.

Try a location column

Site is a location column with inline editing. Double-click a site, type at least three letters such as "lyon" or "port", and pick a suggestion from the example's geocode action, which searches a short list of places where a real host calls its geocoder. Typing "48.85, 2.35" fills the coordinates directly. The advanced filters offer the location rules described below. The preview uses the store-opening projects shared by every view guide; see the example setup for project-config.ts and the in-memory host.

Expand ↗

Sample data. Changes stay in this preview.

Expand ↗

Sample data. Changes stay in this preview.

Cells and record details

A cell shows a pin and the label, else the address, else the coordinates (five decimals at most, about one meter). Hovering it shows the coordinates. The record view shows "label · address" when both are set. Formatted exports write the same text as the cell.

Editing

The editor has an address field, a name, the latitude and the longitude, and Clear:

  • With actions.geocode, typing at least three characters in the address field shows up to eight suggestions, 300 ms after the last key; arrow keys move through them and Enter picks one, which fills the address, the name and the coordinates. Without geocode, the field is a plain address and the coordinates are typed in.

  • Typing "lat, lng" in the address field, for example 48.8566, 2.3522, fills the coordinates.

  • Inline, the editor floats above the table under its cell, adds Cancel and Done, and saves when focus leaves it, like the other inline editors. Escape cancels.

  • Generated create and edit forms use the same editor. In a form catalogue, createLocationField({ name, label }) builds the field (React).

  • Incomplete or out-of-range coordinates show a validation error and block saving. Changing a valid location to an invalid draft never saves the previous location silently. Correct the coordinates or use Clear; an empty optional location can be saved, while a required location must be filled.

  • In the Form view, a location question uses the same editor. The form keeps the place as JSON text in its draft, so conditions on a location question should use "is empty" or "is not empty", and hidden fixed values cannot hold a place.

Address suggestions

Declare geocode next to list and the other actions. It receives the typed text and { locale, signal }, and returns places, best first:

geocode: (query: string, context?: { locale?: string; signal?: AbortSignal }) =>
  Promise<Array<{ lat: number; lng: number; label: string; address?: string }>>

The table debounces the calls, aborts the previous request with signal when the user keeps typing, keeps at most eight valid results and ignores the others. Call your geocoding provider from your server, so its key and rate limits stay there. This Next.js route calls Photon, an OpenStreetMap geocoder:

app/api/geocode/route.ts
import { NextResponse } from "next/server";

const PHOTON_URL = process.env.PHOTON_URL ?? "https://photon.komoot.io/api/";
const LANGUAGES = new Set(["en", "fr", "de"]);

type PhotonFeature = {
  geometry: { coordinates: [number, number] };
  properties: Record<string, string | undefined>;
};

export async function GET(request: Request) {
  // Check the session here: the route should only serve signed-in users.
  const params = new URL(request.url).searchParams;
  const query = (params.get("q") ?? "").trim().slice(0, 200);
  if (query.length < 3) {
    return NextResponse.json([]);
  }
  const language = (params.get("locale") ?? "en").slice(0, 2);
  const url = new URL(PHOTON_URL);
  url.searchParams.set("q", query);
  url.searchParams.set("limit", "8");
  if (LANGUAGES.has(language)) {
    url.searchParams.set("lang", language);
  }
  const response = await fetch(url, { signal: request.signal });
  if (!response.ok) {
    return NextResponse.json({ error: "Geocoding failed" }, { status: 502 });
  }
  const { features } = (await response.json()) as { features: PhotonFeature[] };
  return NextResponse.json(
    features.map(({ geometry, properties: p }) => {
      const street = [p.housenumber, p.street].filter(Boolean).join(" ");
      const address = [street, [p.postcode, p.city].filter(Boolean).join(" "), p.country]
        .filter(Boolean)
        .join(", ");
      return {
        lng: geometry.coordinates[0],
        lat: geometry.coordinates[1],
        label: p.name ?? (street || address),
        address,
      };
    })
  );
}
table-actions.ts
geocode: async (query, { locale, signal } = {}) => {
  const params = new URLSearchParams({ q: query, locale: locale ?? "en" });
  const response = await fetch(`/api/geocode?${params}`, { signal });
  if (!response.ok) {
    throw new Error("Geocoding failed");
  }
  return response.json();
},

A failed search shows "The search failed" and leaves the typed text. The public Photon instance is meant for light use: in production, run your own instance or use a provider whose terms allow search-as-you-type, and show the attribution it requires. Nominatim's public service, for example, does not allow autocomplete. The same geocode action converts addresses during imports.

Filters

Location columns offer four operators in the column filters and in the advanced filter builder:

OperatorvaluesMatches
isEmptyNoneRecords without a place.
isNotEmptyNoneRecords with a place.
withinDistance[lat, lng, km]Places within km kilometers of the point (great-circle distance).
withinBounds[west, south, east, north]Places inside the rectangle; west greater than east crosses the antimeridian.

The inputs are labelled number fields. A rule whose values are incomplete matches every record, as for other types. An advanced filter rule arrives in list as, for example:

{ id: "near-paris", columnId: "site", type: "location", operator: "withinDistance", values: [48.8566, 2.3522, 10], isActive: true }

The shared matchesContractFilter implements these operators for local data and the demos. On PostgreSQL with PostGIS and the site_geom column of the Map view example, the two spatial operators become:

-- withinDistance: [lat, lng, km]
st_dwithin(site_geom::geography, st_setsrid(st_makepoint($2, $1), 4326)::geography, $3 * 1000)
-- withinBounds: [west, south, east, north], when west <= east
site_geom && st_makeenvelope($1, $2, $3, $4, 4326)

Import and export

  • CSV import reads "lat,lng" (also with ; or a space between the numbers) and JSON places. Other text is an address: with actions.geocode, each distinct address is geocoded once before the review step, one request at a time, and the first suggestion is kept with the address. Without geocode, or when nothing is found, the cell is an invalid_location error.

  • Exports write the formatted place (the label, else the address, else the coordinates) when Values is "As displayed", and "lat,lng" when it is "Raw", which the import reads back.

Connectors

The Notion and Google Sheets connectors write places as "lat, lng" text: a rich-text property in Notion and a text cell in Google Sheets, and they compare places as that text. Values pulled back from the target are stored as that text, which the table reads as a place.

Known limits:

  • The label and the address are not kept on a round trip through Notion or Google Sheets: only the coordinates come back.

  • A sheet with separate latitude and longitude columns is not combined into one place. Map one text column holding "lat, lng" instead.

Translations

The editor, cells and filters have built-in English and French labels; French is used when the locale starts with fr. Override any of them with flat location.<key> keys in the table's translations, in React and Vue, for example "location.search": "Find an address".

  • Editor: location, pin, label, address, latitude, longitude, search, searching, noResults, searchFailed, suggestions, clear, done, cancel, edit, invalidCoordinates, coordinatesHint, noLocation.

  • Filters: withinDistance, withinBounds, distance, west, south, east, north, withinKm (with {km} and {point}), inArea (with {south}, {west}, {north} and {east}).

The operator names in the filter menus are filters.operators.within_distance and filters.operators.within_bounds.

Compatibility notes

The column type union now includes "location", and so do InlineEditEditor and FormFieldType; the filter operators gain withinDistance and withinBounds. If your code keeps an exhaustive Record<…> over one of these unions, such as icons per column type, add a location entry so it keeps compiling.