A community map + personal "manholedex" for the 470 Pokémon manhole covers (poké-fúta) scattered across Japan. Browse the covers on an OpenStreetMap, sign up, and mark each one you've visited.
Live: https://dexkepo.pages.dev
Source for the manhole catalogue: the sibling pokemap project, which scraped all 470 covers into a JSON file.
| Layer | Choice |
|---|---|
| API | NestJS + Prisma + JWT auth + class-validator |
| Database | Neon Postgres (serverless, free tier) |
| Frontend | SvelteKit (TypeScript, adapter-static SPA) |
| Map | Leaflet + OpenStreetMap raster tiles + leaflet.markercluster |
| Styling | Tailwind CSS |
| Data fetching | @tanstack/svelte-query |
| API host | Back4App Containers (free tier, no card) |
| Frontend host | Cloudflare Pages |
| Shared types | npm workspace @dexkepo/shared |
dexkepo/
├── package.json # root workspaces config + dev scripts
├── Dockerfile # builds the API container (used by Back4App)
├── .dockerignore
├── shared/ # @dexkepo/shared — TS types used by both api and web
│ └── src/
│ ├── manhole.ts # Manhole, ManholeSummary, PokemonRef
│ ├── auth.ts # RegisterRequest, LoginRequest, AuthResponse
│ ├── dex.ts # DexEntry, DexEntryWithManhole
│ └── index.ts
├── api/ # @dexkepo/api — NestJS server
│ ├── prisma/
│ │ ├── schema.prisma # User, Manhole, DexEntry
│ │ ├── seed.ts # seeds 470 manholes from data/manholes.json
│ │ └── migrations/
│ ├── data/manholes.json # snapshot from pokemap project
│ └── src/
│ ├── main.ts
│ ├── app.module.ts
│ ├── auth/ # POST /auth/register, POST /auth/login
│ ├── users/ # GET /users/me
│ ├── manholes/ # GET /manholes (slim), GET /manholes/:no (full)
│ ├── dex/ # GET/POST/DELETE /me/dex[/:no]
│ ├── health/ # GET /health
│ ├── prisma/ # PrismaService + global module
│ └── common/ # JwtAuthGuard, @CurrentUser decorator
└── web/ # @dexkepo/web — SvelteKit SPA
├── svelte.config.js # adapter-static, fallback index.html
├── tailwind.config.ts
├── static/
│ └── _redirects # CF Pages SPA fallback: /* /index.html 200
└── src/
├── app.html
├── app.css # Tailwind + Leaflet overrides
├── routes/
│ ├── +layout.svelte # Header + svelte-query provider; imports Leaflet CSS
│ ├── +page.svelte # map page
│ ├── login/+page.svelte
│ ├── register/+page.svelte
│ └── me/+page.svelte # profile + visited list
└── lib/
├── api/ # fetch wrapper, manholes/auth/dex clients
├── stores/ # auth + filters (Svelte stores)
├── map/ # Map.svelte + marker icon factories
└── components/ # Header, FilterBar, ManholePanel
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/health |
— | no DB touch, used for uptime checks |
POST |
/auth/register |
— | {pseudo, email, password} → {accessToken, user} |
POST |
/auth/login |
— | {email, password} → {accessToken, user} |
GET |
/users/me |
JWT | current user |
GET |
/manholes |
— | slim list (470 rows, ~43kB) — only manholeNo, name, prefEnName, lat, lng |
GET |
/manholes/:manholeNo |
— | full Manhole including pokemon + picture |
GET |
/me/dex |
JWT | user's visited manholes (joined) |
POST |
/me/dex/:manholeNo |
JWT | mark visited (idempotent upsert) |
DELETE |
/me/dex/:manholeNo |
JWT | unmark |
The split between slim list + detailed single is intentional: keeps the 470-row response small enough to be reliable on tight-RAM hosts.
- Node.js 24+ (Node 20 EoL'd April 2026)
- A Neon Postgres database (free at neon.tech)
git clone git@github.com:Sufiane/dexkepo.git
cd dexkepo
npm install # installs all workspaces (api + web + shared)Create api/.env:
DATABASE_URL="postgresql://USER:PASS@HOST/db?sslmode=require"
JWT_SECRET="some-long-random-string"
JWT_EXPIRES_IN="7d"
PORT=7777
Create web/.env:
VITE_API_BASE_URL=http://localhost:7777
Initialize the database:
npm --workspace api exec -- prisma migrate dev --name init
npm --workspace api run prisma:seed # inserts 470 manholesIn one terminal:
npm run dev:api # NestJS on :7777In another:
npm run dev:web # SvelteKit on :5173Open http://localhost:5173.
| Command | What |
|---|---|
npm run dev:api |
nest start --watch |
npm run dev:web |
vite dev |
npm run build:api |
nest build |
npm run build:web |
vite build → web/build/ |
npm run typecheck |
typecheck shared + api + web |
npm --workspace api run prisma:seed |
re-seed the manhole catalogue |
The repo has a root Dockerfile — multi-stage Node 24 build that bundles the shared workspace alongside the API.
- Sign in at back4app.com
- New App → Containers as a Service → connect this repo
- Build settings:
- Branch:
main - Root Directory:
./ - Port:
8080
- Branch:
- Environment variables:
DATABASE_URL— your Neon connection stringJWT_SECRET— long random stringJWT_EXPIRES_IN—7dNODE_ENV—production
- Deploy.
On boot the container runs npx prisma migrate deploy then node dist/main.js.
Free-tier caveat: the public URL changes every hour. Each rotation requires updating VITE_API_BASE_URL on Cloudflare Pages and retrying the deployment (see below) so the new URL is baked into the JS bundle. This is the main reason to upgrade off the free tier eventually.
- CF Pages → Connect to Git → pick
Sufiane/dexkepo - Build settings:
- Production branch:
main - Framework preset:
SvelteKit - Build command:
npm install && npm run build:web - Build output directory:
web/build - Root directory: (blank — needs repo root so workspaces resolve)
- Production branch:
- Environment variables (Production):
VITE_API_BASE_URL— full URL withhttps://(e.g.https://dexkepo-2bfmm50i.b4a.run)NODE_VERSION—24
- Deploy.
- Get the new URL from the Back4App dashboard
- CF Pages → Settings → Environment Variables → edit
VITE_API_BASE_URL - Deployments tab → latest → Retry deployment (Vite inlines env vars at build time; updating the value alone does nothing)
A few things worth knowing before touching the code:
- Bundle Leaflet CSS via JS import, not
@importin CSS. The PostCSS@importresolution fromnode_modulesis flaky in some build environments (notably Cloudflare Pages) and can silently drop the bundle. See +layout.svelte. - Tailwind's preflight resets
img { max-width: 100%; height: auto }which clashes with Leaflet tiles. The override lives in app.css. Don't remove it. - SPA routing on CF Pages needs
web/static/_redirects—/* /index.html 200— so deep links like/logindon't 404. - Map sizing: Leaflet has to be told to recompute size after flex parents settle.
Map.sveltecallsinvalidateSize()after the first paint and again on every parent resize viaResizeObserver. Don't skip this if you refactor the page layout. - Manhole PK is
manhole_no(string, from the source JSON). Not a UUID, not an int. Keeps joins simple. /manholesreturns a slim summary (5 fields per row, ~43kB total). Full detail comes from/manholes/:noand is loaded only when the user opens a marker popup. Don't fatten the list endpoint — it'll OOM low-RAM hosts.- JWT is stored in
localStorage— fine for hobby, XSS-vulnerable. Switch to httpOnly cookies if this ever sees real users.
- Phase 3 — photo upload as visit proof. Add
photoUrltoDexEntry, use Cloudflare R2 / Supabase Storage for files. - Tighten CORS from
*to the CF Pages origin. - Refresh tokens, password reset, email verification.
- Filter improvements: Pokémon name search across all 470 covers.
- Custom domain on Cloudflare Pages.
- i18n — Japanese UI.
- Migrate to MapLibre vector tiles if the raster look feels dated.
Personal hobby project. Manhole imagery copyright The Pokémon Company; data scraped from their public catalogue is used here for non-commercial fan use.