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