A focused App Router demo that shows three distinct backend capabilities working together in a single project — no fluff, just correct implementations.
| Feature | What it does |
|---|---|
| Multi-tenant middleware | Reads the subdomain from Host, resolves it against a mock Redis store, injects x-tenant-id / x-tenant-name headers, and returns 404 for unknown tenants |
| PostGIS geofence | POST /api/geofence accepts [lat, lng] and answers inside/outside using a raw ST_Contains query via Prisma $queryRaw |
| Socket.io realtime | A shared counter that broadcasts updates to every connected browser tab in under 500 ms |
| Requirement | Version |
|---|---|
| Node.js | 18 or later |
| PostgreSQL | 14 + with the PostGIS extension enabled |
No Docker required. Connect to any local or remote PostGIS-enabled Postgres instance.
# 1. Clone and install
git clone <repo-url>
cd multi-tenant
npm install
# 2. Configure the database URL
cp .env.example .env
# Edit .env and set DATABASE_URL to your Postgres connection string
# 3. Create the database and enable PostGIS (run inside psql)
# CREATE DATABASE geofence_demo;
# \c geofence_demo
# CREATE EXTENSION IF NOT EXISTS postgis;
# 4. Push the Prisma schema
npm run db:push
# 5. Start the dev server
npm run devOpen http://localhost:3000.
Important: always start with
npm run dev, notnext dev.
Socket.io needs to attach to the same HTTP server as Next.js — that custom wiring lives inserver.ts.
When the server starts you will see:
> Ready on http://localhost:3000
[db] Connected ✓
[db] PostGIS: 3.x ...
If the database is unreachable you will see [db] Not connected ✗ with the error message — the rest of the app still works, only the geofence API will fail.
The middleware runs on every request before any route handler.
Flow:
- Reads the
Hostheader (e.g.acme.localhost:3000) - Strips the port and root host to extract the subdomain (
acme) - Looks up the slug in the mock Redis store (
lib/redis-mock.ts) - Known tenant → injects
x-tenant-idandx-tenant-nameinto the request headers and continues - Unknown tenant → returns
404 Unknown tenantimmediately
Subdomains on localhost
Modern browsers resolve *.localhost to 127.0.0.1 — no /etc/hosts changes needed.
| URL | Expected result |
|---|---|
| http://acme.localhost:3000/tenant | 200 — tenant headers shown |
| http://globex.localhost:3000/tenant | 200 — tenant headers shown |
| http://unknown.localhost:3000/tenant | 404 Unknown tenant |
| http://localhost:3000/tenant | 200 — no tenant headers (apex host) |
Known tenants: acme, globex (edit lib/redis-mock.ts to add more).
Test with curl:
# Returns { tenantId, tenantName }
curl -s http://acme.localhost:3000/api/tenant | jq
# Returns 404
curl -si http://unknown.localhost:3000/api/tenantEndpoint: POST /api/geofence
Body: [lat, lng] — a JSON array of two numbers
Response: { inside: boolean, lat, lng }
The polygon is a hardcoded rectangle around downtown San Francisco (see lib/geofence-constants.ts). The check runs entirely inside PostgreSQL using ST_Contains.
SELECT ST_Contains(
ST_GeomFromText('<WKT polygon>', 4326),
ST_SetSRID(ST_MakePoint(<lng>, <lat>), 4326)
) AS insideTest with curl:
# Inside the fence (SF downtown)
curl -s -X POST http://localhost:3000/api/geofence \
-H 'Content-Type: application/json' \
-d '[37.78, -122.40]' | jq
# → { "inside": true, "lat": 37.78, "lng": -122.4 }
# Outside the fence (Oakland)
curl -s -X POST http://localhost:3000/api/geofence \
-H 'Content-Type: application/json' \
-d '[37.80, -122.27]' | jq
# → { "inside": false, ... }UI: http://localhost:3000/geofence — includes quick presets and coordinate inputs.
UI: http://localhost:3000/realtime
- Open the page in two browser tabs
- Click Increment in one tab — the counter updates in both tabs instantly
- Click Reset — all tabs return to
0
The round-trip latency is displayed after each action.
Socket events:
| Event (client → server) | Effect |
|---|---|
increment |
counter += 1, broadcast counter to all sockets |
counter-reset |
counter = 0, broadcast counter to all sockets |
| Event (server → client) | Payload |
|---|---|
counter |
number — current counter value |
.
├── server.ts # Custom HTTP server: Next.js + Socket.io
├── middleware.ts # Subdomain extraction → mock Redis → headers / 404
│
├── lib/
│ ├── redis-mock.ts # In-memory tenant store (stand-in for Redis)
│ ├── subdomain.ts # Host → subdomain parser
│ ├── geofence.ts # isPointInsideFence() via Prisma $queryRaw
│ ├── geofence-constants.ts # Hardcoded polygon WKT + bounds
│ ├── prisma.ts # Prisma client singleton
│ ├── counter-socket.ts # Socket.io counter event handlers
│ └── check-db.ts # Startup DB connection + PostGIS check
│
├── app/
│ ├── page.tsx # Home — feature overview
│ ├── tenant/page.tsx # Displays injected tenant headers
│ ├── geofence/page.tsx # Geofence checker UI
│ ├── realtime/page.tsx # Socket.io counter UI
│ └── api/
│ ├── geofence/route.ts # POST [lat, lng] → ST_Contains
│ └── tenant/route.ts # GET — returns tenant headers as JSON
│
└── prisma/
└── schema.prisma # Minimal schema (geofence uses raw SQL only)
| Variable | Description | Example |
|---|---|---|
DATABASE_URL |
PostgreSQL connection string | postgresql://user:pass@localhost:5432/geofence_demo |
PORT |
Server port (default 3000) |
3001 |
ROOT_HOST |
Root hostname for subdomain parsing (default localhost) |
lvh.me |
Copy .env.example to .env and fill in DATABASE_URL.
| Script | Description |
|---|---|
npm run dev |
Start dev server (Next.js + Socket.io via server.ts) |
npm run build |
Production build |
npm start |
Run production build |
npm run db:push |
Apply Prisma schema to the database |
npm run db:generate |
Regenerate Prisma client |
npm run build
npm startEnsure DATABASE_URL points at a PostGIS-enabled PostgreSQL instance before starting.