A Nuxt 4 application for discovering locations that accept cryptocurrency payments in Lugano.
- πΊοΈ Browse crypto-friendly locations with images and details
- π Hybrid search combining PostgreSQL FTS + semantic embeddings
- β‘ Fast autocomplete with text search and background embedding precomputation
- π― Category-based filtering and opening hours filtering
- π Optional location-based search with Cloudflare IP geolocation
- πΎ PostgreSQL with PostGIS + pgvector for geospatial and semantic queries
- π€ OpenAI embeddings for intelligent category matching
- π¨ UnoCSS with Nimiq design system (attributify mode)
- π§© Accessible UI with Reka UI components
- π Deployed on NuxtHub/Cloudflare
- πΌοΈ Image proxying through NuxtHub Blob cache
- Framework: Nuxt 4
- Database: PostgreSQL with PostGIS and pgvector extensions
- ORM: Drizzle ORM
- AI: OpenAI text-embedding-3-small for semantic search
- Cache: NuxtHub KV for embedding storage
- Styling: UnoCSS with
nimiq-cssandunocss-preset-onmax - UI Components: Reka UI
- Validation: Valibot
- Deployment: NuxtHub/Cloudflare
First, install pnpm if you haven't already.
# Install dependencies
pnpm install
# Set up environment variables
cp .env.example .env
# Edit .env with your Supabase DATABASE_URL and API keys
# Set up database (run migrations and seed data)
DATABASE_URL="your_supabase_url" pnpm run db:setup# Start development server
pnpm run devThe app will be available at http://localhost:3000
Note: Make sure your DATABASE_URL in .env points to a valid Supabase PostgreSQL instance with PostGIS and pgvector extensions enabled.
Location photos are automatically cached using NuxtHub Blob storage to reduce Google Maps API costs:
- Frontend renders images via
/images/location/{uuid} - First request fetches from Google Maps API or external URL (auto-detects content type)
- Image is cached in NuxtHub Blob storage with correct MIME type
- Subsequent requests serve from cache
pay-app/
βββ app/
β βββ app.vue # Root component
β βββ pages/
β βββ index.vue # Main locations page with search
βββ server/
β βββ api/
β β βββ categories.get.ts # Get all categories
β β βββ locations/
β β β βββ [uuid].get.ts # Get single location by UUID
β β βββ search/
β β βββ index.get.ts # Hybrid search (text + semantic)
β β βββ autocomplete.get.ts # Fast text-only autocomplete
β βββ utils/
β βββ drizzle.ts # Database utilities and types
β βββ geoip.ts # GeoIP location service
β βββ embeddings.ts # OpenAI embedding generation with cache
β βββ search.ts # Search utilities (text, semantic, categories)
β βββ open-now.ts # Opening hours filtering
βββ shared/
β βββ types/
β βββ index.ts # Shared TypeScript types
βββ database/
β βββ schema.ts # Drizzle schema (3 tables, PostGIS + pgvector)
β βββ migrations/ # Drizzle migrations (auto-generated)
β βββ scripts/
β β βββ db-setup.ts # Database setup (migrations + seeding)
β β βββ reset-db.ts # Drop all tables
β β βββ generate-category-embeddings.ts # Generate embeddings
β β βββ categories.json # 301 Google Maps categories with embeddings
β βββ sql/
β βββ 1.rls-policies.sql # Row Level Security policies
β βββ 2.locations.sql # Dummy location data
βββ nuxt.config.ts # Nuxt configuration
βββ uno.config.ts # UnoCSS configuration
βββ drizzle.config.ts # Drizzle ORM configuration
βββ CLAUDE.md # AI development guidance
Hybrid search combining PostgreSQL full-text search with semantic category matching via vector embeddings.
flowchart TB
User[User Types Query] --> AC[Autocomplete: PostgreSQL FTS]
AC --> ACResults[Results with Highlighting]
AC -.Background.-> Cache[Cache Embedding in KV]
ACResults --> Action{User Action}
Action -->|Click Location| Single[GET /api/locations/uuid]
Action -->|Submit Search| Hybrid[Hybrid Search]
Hybrid --> Text[Text Search: PostgreSQL FTS]
Hybrid --> Semantic[Semantic Search: pgvector]
Semantic --> Embed[Get Cached Embedding]
Embed --> Similar[Find Similar Categories]
Similar --> CatLocs[Get Locations by Category]
Text --> Merge[Merge & Deduplicate]
CatLocs --> Merge
Merge --> Filters[Apply Filters]
Filters --> Results[Final Results]
Key Points:
- Autocomplete: PostgreSQL FTS only (fast, 10-50ms) + background embedding precomputation
- Hybrid Search: FTS + vector embeddings for comprehensive results
- Embedding Cache: NuxtHub KV with permanent storage (no TTL)
- Text Search:
to_tsvector+to_tsquerywithts_headlinehighlighting on name and address - Semantic Search: OpenAI text-embedding-3-small (1536-dim) + pgvector cosine similarity
- Category Matching: Similarity threshold 0.7 (configurable), returns top 5 similar categories
- Merge Strategy: Text results first, then semantic results (deduplicated by UUID)
- Filters: Category filters and opening hours filters applied after merge
Fetch a single location by UUID.
Path Parameters:
uuid: Location UUID
Response:
{
uuid: string
name: string
address: string
latitude: number
longitude: number
rating?: number
photo?: string
gmapsPlaceId: string
gmapsUrl: string
website?: string
source: 'naka' | 'bluecode'
timezone: string
openingHours?: string
categories: Array<{id: string, name: string, icon: string}>
createdAt: Date
updatedAt: Date
}Hybrid search endpoint combining PostgreSQL FTS with semantic category matching.
Query Parameters:
q(required): Search querylat/lng(optional): User location for future distance sortingopenNow(optional): Filter by opening hours (boolean)
Response:
Array<{
uuid: string
name: string
address: string
latitude: number
longitude: number
rating?: number
photo?: string
gmapsPlaceId: string
gmapsUrl: string
website?: string
source: 'naka' | 'bluecode'
timezone: string
openingHours?: string
categoryIds: string // Comma-separated category IDs
categories: Array<{ id: string, name: string, icon: string }>
createdAt: Date
updatedAt: Date
}>Fast text-only search for autocomplete dropdown (PostgreSQL FTS only). Precomputes embeddings in background for future hybrid searches.
Query Parameters:
q(required, min 2 chars): Search query
Response:
Array<{
// Same as /api/search response
highlightedName: string // HTML with <mark> tags highlighting matches
// ... other fields
}>The database uses PostgreSQL with PostGIS and pgvector extensions, with a normalized relational schema:
Stores category types with vector embeddings for semantic search.
id(text, PK): Category ID (e.g., "restaurant", "cafe")name(text): Display name (e.g., "Restaurant", "Cafe")icon(text): Icon identifier for UIembedding(vector(1536)): OpenAI text-embedding-3-small vector
Indexes:
- Primary key on
id - Vector index for cosine similarity search on
embedding
Main location data with PostGIS geometry and opening hours.
uuid(text, PK): Auto-generated unique identifiername(text): Location nameaddress(text): Full addresslocation(geometry(point, 4326)): PostGIS point - Stores lat/lng as geographic pointrating(double precision): User rating (0-5, optional)photo(text): Image URL (optional)gmapsPlaceId(text, unique): Google Maps Place IDgmapsUrl(text): Google Maps URLwebsite(text): Location website (optional)source(varchar): Data source (nakaorbluecode)timezone(text): IANA timezone identifier (e.g., "Europe/Zurich")openingHours(text): JSON string with weekly opening hours (optional)createdAt/updatedAt(timestamp): Timestamps
Indexes:
- Primary key on
uuid - Unique index on
gmapsPlaceId - GIST spatial index on
locationfor efficient proximity queries
PostGIS Functions:
- Extract longitude:
ST_X(location) - Extract latitude:
ST_Y(location) - Calculate distance:
ST_Distance(location1, location2) - Find within area:
ST_Within(location, boundary)
Junction table for many-to-many relationship between locations and categories.
locationUuid(text, FK): Foreign key to locations.uuid (cascade delete)categoryId(text, FK): Foreign key to categories.id (cascade delete)createdAt(timestamp): Creation timestamp
Indexes:
- Composite primary key on (locationUuid, categoryId)
- Index on
locationUuidfor joins - Index on
categoryIdfor reverse lookups
# Development
pnpm run dev # Start dev server
pnpm run build # Build for production
pnpm run preview # Preview production build
# Database
pnpm run db:setup # Run migrations and seed data (requires DATABASE_URL)
pnpm run db:generate # Generate Drizzle migrations from schema changes
pnpm run db:generate-category-embeddings # Generate OpenAI embeddings for categories
# Code Quality
pnpm run lint # Run ESLint
pnpm run lint:fix # Fix ESLint issues
pnpm run typecheck # Run TypeScript checksCreate a .env file in the project root:
# PostgreSQL Configuration (Supabase Remote)
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-1-eu-central-1.pooler.supabase.com:6543/postgres
# API Keys
GOOGLE_API_KEY=your_google_api_key
# OpenAI (for generating embeddings)
OPENAI_API_KEY=your_openai_api_keyNote: The DATABASE_URL should use Supabase's connection pooler (port 6543) with prepare: false for transaction pooling mode.
The database uses Supabase (remote PostgreSQL with PostGIS and pgvector extensions).
Setup:
# Set up database (run migrations and seed data)
DATABASE_URL="your_supabase_url" pnpm run db:setup
# Generate new migrations after schema changes
pnpm run db:generate
# Generate category embeddings (one-time, already committed)
OPENAI_API_KEY="sk-..." pnpm run db:generate-category-embeddingsDatabase Structure:
- 301 categories with OpenAI embeddings (1536 dimensions each)
- Embeddings stored directly in
database/scripts/categories.jsonas arrays - Each category object includes an
embeddingsfield with 1536 float values
The application uses a multi-layer caching strategy to optimize response times and reduce database load:
| Endpoint | Server Cache | SWR | CDN/Browser Cache | Strategy |
|---|---|---|---|---|
/api/categories |
12h | β | 1h (SWR: 12h) | Low variance, recalculated counts cached server-side |
/api/locations/[uuid] |
15min | β | 15min (SWR: 15min) | Frequently accessed single locations |
/api/locations/stats |
24h | β | - | Summary stats for widgets |
/api/search |
24h | β | - | Heavy queries keyed by query+flags |
/api/search/autocomplete |
7d | β | - | Text search + warm embedding cache |
/api/locations |
None | - | - | High variance (filters, open/closed state) |
Implementation:
- Server Cache: Nitro's
defineCachedEventHandlerwith configurable TTL and SWR - HTTP Headers:
Cache-Controlheaders set viasetResponseHeaderfor CDN/browser - Route Rules: Defined in
nuxt.config.tsfor Cloudflare/NuxtHub compatibility - Embedding Cache: NuxtHub KV with permanent storage (no TTL) for OpenAI embeddings
Notes:
- Endpoints with dynamic filters (
/api/locations) remain uncached to avoid stale results - 404 responses bypass cache to avoid caching missing resources
- SWR (Stale-While-Revalidate) ensures users get instant responses while cache refreshes in background
- Nuxt Documentation
- NuxtHub Documentation
- Drizzle ORM Documentation
- UnoCSS Documentation
- Nimiq CSS
- PostGIS Documentation
- pgvector Documentation
- Vercel AI SDK
MIT