API Development Guide
You are working on the 100cims API (packages/api), a Next.js + Elysia hybrid backend.
Key Files
| File | Purpose |
|---|
src/api/routes/index.ts | Elysia app composition, error handling |
src/app/api/[[...slugs]]/route.ts | Next.js catch-all for Elysia |
src/db/schema.ts | Drizzle schema (source of truth) |
src/db/index.ts | Database client |
src/api/routes/@shared/jwt.ts | JWT middleware |
src/api/routes/@shared/s3.ts | S3 upload utilities |
src/api/lib/sheets.ts | Google Sheets logging |
drizzle.config.ts | Database connection config |
Architecture
Hybrid Stack
- Next.js 15 (App Router) for web pages and runtime
- Elysia 1.4 for API routes (mounted at
/api/*)
- Drizzle ORM with PostgreSQL
- TypeBox for schema validation
Why This Hybrid?
Elysia provides excellent TypeScript inference, OpenAPI generation, and performance while Next.js handles the server runtime and potential web pages.
Directory Structure
/src/api/: All Elysia API code
/routes/: Route handlers (public, protected, @shared)
/schemas/: TypeBox validation schemas
/lib/: Utilities (sheets, dates, images, slug)
/src/db/: Database schema and client
/src/app/: Next.js pages and API catch-all route
Shared Utilities
| File | Purpose |
|---|
src/api/lib/slug.ts | generateSlug() - URL-friendly slug generation |
src/api/lib/images.ts | isBase64SizeValid() - Image size validation |
src/api/lib/sheets.ts | Google Sheets logging utilities |
src/api/lib/dates.ts | Date formatting utilities |
Key Patterns
Route Organization
/api/routes/
├── @shared/ # Middleware, JWT, S3, types
├── public/ # No auth required
│ ├── mountains.route.ts
│ ├── challenge.route.ts
│ └── hiscores.route.ts
├── protected/ # JWT required
│ ├── summit.route.ts
│ ├── user.route.ts
│ ├── plan.route.ts
│ ├── mountains/ # Folder-based organization
│ │ ├── index.ts
│ │ ├── my-list.route.ts
│ │ └── update.route.ts
│ └── community-challenge/
│ ├── index.ts
│ ├── create.route.ts
│ ├── update.route.ts
│ └── delete.route.ts
└── index.ts # Compose all routes
Folder-based routes: Group related endpoints in folders with an index.ts that composes them with a prefix. Each endpoint gets its own file.
Creating Routes
typescript
1import { Elysia } from 'elysia';
2import { db } from '@/db';
3import { userSchema } from '@/api/schemas';
4
5export const userRoute = new Elysia({ prefix: '/user', tags: ['users'] })
6 .get('/:id', async ({ params }) => {
7 const user = await db.query.user.findFirst({
8 where: (u, { eq }) => eq(u.id, params.id)
9 });
10 return user;
11 }, {
12 detail: { summary: 'Get user by ID' },
13 params: userSchema.params,
14 response: userSchema.response
15 });
Protected Routes
typescript
1import { jwt } from '@/api/routes/@shared/jwt';
2import { store } from '@/api/routes/@shared/store';
3
4export const summitRoute = new Elysia({ prefix: '/summit', tags: ['summits'] })
5 .use(jwt)
6 .use(store)
7 .derive(async ({ bearer, store }) => {
8 const payload = await bearer(bearer);
9 store.userId = payload.userId;
10 })
11 .post('/', async ({ body, store }) => {
12 // store.userId available from JWT
13 const summit = await db.insert(summitTable).values({
14 userId: store.userId,
15 mountainId: body.mountainId
16 });
17 return summit;
18 });
Database Queries
typescript
1import { db } from '@/db';
2import { user, summit, mountain } from '@/db/schema';
3import { eq, desc } from 'drizzle-orm';
4
5// Simple query
6const users = await db.select().from(user).where(eq(user.id, userId));
7
8// Join query
9const summits = await db
10 .select({
11 id: summit.id,
12 mountainName: mountain.name,
13 date: summit.createdAt
14 })
15 .from(summit)
16 .leftJoin(mountain, eq(summit.mountainId, mountain.id))
17 .where(eq(summit.userId, userId))
18 .orderBy(desc(summit.createdAt));
Schema Validation
typescript
1import { t } from 'elysia';
2
3export const summitSchema = {
4 body: t.Object({
5 mountainId: t.String(),
6 date: t.Optional(t.String()),
7 image: t.Optional(t.String())
8 }),
9 response: {
10 200: t.Object({
11 id: t.String(),
12 mountainId: t.String(),
13 userId: t.String()
14 })
15 }
16};
For paginated endpoints, use this pattern for backwards compatibility:
typescript
1// Schema
2export const PaginatedItemsSchema = t.Object({
3 items: t.Array(ItemSchema),
4 pagination: t.Object({
5 page: t.Number(),
6 pageSize: t.Number(),
7 totalItems: t.Number(),
8 totalPages: t.Number(),
9 hasMore: t.Boolean(),
10 }),
11});
12
13// Route handler - backwards compatible
14const isPaginated = query.page !== undefined || query.limit !== undefined;
15
16if (isPaginated) {
17 // Return paginated results with count query
18 return { items: results, pagination: { page, pageSize, totalItems, totalPages, hasMore } };
19}
20
21// No pagination params = return ALL results (backwards compatible)
22return { items: results, pagination: { page: 1, pageSize: results.length, totalItems: results.length, totalPages: 1, hasMore: false } };
Key: Old clients without pagination params get all results. New clients can paginate.
Common Tasks
Add New Endpoint
- Create schema in
/api/schemas/
- Create route file in
/routes/public/ or /protected/
- Import and use in
/routes/index.ts
- Mobile app: Run
yarn generate-api-types
Database Migration
- Update
/src/db/schema.ts
- Run
yarn drizzle-kit push (pushes to DB)
- Verify schema changes in database
Image Upload to S3
typescript
1import { putImageOnS3 } from '@/api/routes/@shared/s3';
2
3const key = `${process.env.APP_NAME}/user/avatar/${userId}.jpeg`;
4await putImageOnS3(key, buffer);
Log to Google Sheets
typescript
1import { addRowToSheets, ERRORS_SPREADSHEET } from '@/api/lib/sheets';
2
3await addRowToSheets(ERRORS_SPREADSHEET, [
4 'error_type',
5 'status_code',
6 'url',
7 'message'
8]);
Environment Variables
DATABASE_URL: PostgreSQL connection string
AUTH_SECRET: JWT signing secret
AWS_*: S3 credentials (region, bucket, access keys)
SHEETS_*: Google service account credentials
APP_NAME: Application name (used in S3 paths)
See .env.example for complete list.
Swagger Documentation
Available at /api/swagger during development. Auto-generated from:
- Route tags
- TypeBox schemas
- OpenAPI metadata in route definitions
Database Schema
See /src/db/schema.ts for full schema. Key tables:
user: OAuth accounts
mountain: Peak data (name, lat/lng, elevation, difficulty)
summit: User summit logs
plan: Group hiking plans
plan_attendee: Plan participants
plan_chat: Chat messages
challenge: Curated challenges
hiscores: Leaderboard
Error Handling
Global error handler in /routes/index.ts:
- Logs all errors to Google Sheets
- Returns appropriate HTTP status codes
- Distinguishes ValidationError, ParseError, generic errors
Deployment
Vercel (configured in root vercel.json):
- Builds from
packages/api
- Environment variables set in Vercel dashboard
- Automatic deployments on main branch