A Pokédex built to showcase Prisma Next — a contract-first data access layer for PostgreSQL.
- ORM collections with custom scopes — chainable
.where(),.include(),.orderBy(), reusable query fragments like.legendary()and.search() - Streaming —
.all()returns anAsyncIterable, stream rows to the client as they arrive - Type-safe aggregations —
.groupBy().aggregate()without raw SQL - Kysely escape hatch —
db.kysely()gives a fully typed Kysely instance derived from the contract - Contract-first schema — TypeScript-defined, deterministic, machine-readable
bun installConfigure apps/server/.env:
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DB_NAME
CORS_ORIGIN=http://localhost:3001Initialize and run:
bun run db:init
bun run dev- Web: http://localhost:3001
- API: http://localhost:3000
Open the web app and click Import Pokemon to seed the database from PokeAPI.
apps/
server/ Hono + oRPC API server
web/ React + TanStack Router frontend
packages/
api/ API router definitions (oRPC procedures)
db/ Prisma Next contract, runtime, ORM collections, seed
config/ Shared TypeScript config
| File | Purpose |
|---|---|
packages/db/src/prisma/contract.ts |
Prisma Next contract (Pokemon + SpawnPoint tables, models, relations) |
packages/db/src/prisma/db.ts |
Runtime bootstrap + connection |
packages/db/src/index.ts |
ORM collections with custom scopes (PokemonCollection, SpawnPointCollection) |
packages/db/src/prisma/seed.ts |
Seed script — fetches from PokeAPI, bulk inserts with createCount() |
packages/api/src/routers/pokedex.ts |
API routes showcasing each Prisma Next feature |
Defined in packages/db/src/index.ts. Collections are reusable, composable query builders — like Rails scopes:
class PokemonCollection extends Collection<Contract, "Pokemon"> {
legendary() { return this.where({ isLegendary: true }); }
byType(type) { return this.where((p) => or(p.primaryType.ilike(...), ...)); }
search(term) { return this.where((p) => or(p.name.ilike(...), ...)); }
}listPokemon in pokedex.ts streams rows with for await...of:
for await (const row of query.include("spawnPoints", (sp) => sp).take(limit).all()) {
yield row;
}typeBreakdown runs type-safe aggregations without raw SQL:
pokemon.groupBy("primaryType").aggregate((agg) => ({ total: agg.count() }))teamBuilder uses db.kysely() for a fully typed Kysely query when the ORM isn't enough:
const kysely = db.kysely(db.runtime());
kysely.selectFrom("pokemon").select([...]).where(...).execute();| Script | Description |
|---|---|
bun run dev |
Run web + server |
bun run db:init |
Emit contract + initialize schema |
bun run db:emit |
Emit contract artifacts |
bun run db:push |
Push schema to database |
bun run db:verify |
Verify marker/contract compatibility |