Features
Geospatial Queries
Vextro automatically syncs point field data to a @convex-dev/geospatial spatial index whenever documents are created, updated, or deleted. This enables efficient bounding-box and nearest-neighbor queries without manual index management.
For querying, you use the GeospatialIndex API directly in your own Convex functions — giving you full control over filtering, pagination, and result shaping.
Setup
Before writing spatial queries, ensure you've completed the point field setup:
npm install @convex-dev/geospatial- Register
app.use(geospatial)inconvex.config.ts - Pass
geospatialComponent: components.geospatialtocreateVextroAdminModule()
Creating a spatial index instance
Use the createSpatialIndex helper from vextro/convex/geo:
import { createSpatialIndex } from "vextro/convex/geo";
import { components } from "./_generated/api";
const geoIndex = createSpatialIndex(components.geospatial); Or use the GeospatialIndex class directly:
import { GeospatialIndex } from "@convex-dev/geospatial";
import { components } from "./_generated/api";
const geoIndex = new GeospatialIndex(components.geospatial); Bounding box query
Find all points within a rectangular region:
// convex/stores.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { createSpatialIndex } from "vextro/convex/geo";
import { components } from "./_generated/api";
const geoIndex = createSpatialIndex(components.geospatial);
export const storesInArea = query({
args: {
west: v.number(),
east: v.number(),
south: v.number(),
north: v.number(),
},
handler: async (ctx, { west, east, south, north }) => {
return await geoIndex.query(ctx, {
shape: {
type: "rectangle",
rectangle: { west, east, south, north },
},
});
},
}); Nearest neighbor query
Find the closest points to a given location:
export const nearestStores = query({
args: {
lat: v.number(),
lng: v.number(),
limit: v.optional(v.number()),
},
handler: async (ctx, { lat, lng, limit }) => {
return await geoIndex.nearest(ctx, {
point: { latitude: lat, longitude: lng },
limit: limit ?? 10,
});
},
}); Filtered spatial queries
If your point field defines filterKeys, you can filter spatial results by those metadata values:
// Collection definition:
// location: f.point({ filterKeys: { category: "category", status: "status" } })
export const nearbyRestaurants = query({
args: {
lat: v.number(),
lng: v.number(),
},
handler: async (ctx, { lat, lng }) => {
return await geoIndex.nearest(ctx, {
point: { latitude: lat, longitude: lng },
filter: (q) =>
q.eq("category", "restaurant").eq("status", "published"),
limit: 20,
});
},
}); Haversine distance utility
For display purposes (e.g., showing "5 miles away" in a UI), use haversineDistance:
import { haversineDistance } from "vextro/convex/geo";
const distance = haversineDistance(
{ lat: 40.7128, lng: -74.006 }, // New York
{ lat: 34.0522, lng: -118.2437 }, // Los Angeles
"mi"
);
// ≈ 2,451 miles | Parameter | Type | Default | Description |
|---|---|---|---|
a | { lat: number; lng: number } | required | First point |
b | { lat: number; lng: number } | required | Second point |
unit | "km" | "mi" | "km" | Distance unit |
Returns a number — the great-circle distance in the specified unit.
Constants
EARTH_RADIUS_KM—6371(mean radius in kilometers)EARTH_RADIUS_MI—3958.8(mean radius in miles)
Re-exported types
For convenience, pointValidator and the PointValue type are re-exported from vextro/convex/geo so you can use them in Convex query args:
import { pointValidator } from "vextro/convex/geo";
export const nearby = query({
args: {
center: pointValidator,
radiusMiles: v.number(),
},
handler: async (ctx, { center, radiusMiles }) => {
return await geoIndex.nearest(ctx, {
point: { latitude: center.lat, longitude: center.lng },
maxDistance: radiusMiles * 1609.34, // convert miles to meters if needed
});
},
}); Automatic index sync
You don't need to manually manage the spatial index. Vextro's admin module hooks handle it:
- Document created — point inserted into spatial index with filter keys and sort key
- Document updated — if point or any filter/sort key field changed, old entry removed and new one inserted
- Document deleted — point removed from spatial index
- Point field cleared — entry removed from spatial index
- Point field populated (was empty) — entry inserted into spatial index
This happens transparently inside createDocumentForCollection, updateDocumentForCollection, and deleteDocumentForCollection.