Creating API Endpoints
Step-by-step guide to creating new API endpoints following our architecture.
Overview
Creating a new API endpoint involves 5 steps:
- Define TypeScript types
- Create Zod validation schema
- Create repository method
- Create service method
- Create API route
- Create client API function
Let’s create a complete example: User Profile API
Step 1: Define Types
Create types for your API in apps/web/types/api.ts:
// apps/web/types/api.ts
export interface UserProfile {
id: string;
email: string;
name: string;
bio?: string;
avatar?: string;
createdAt: string;
}
export interface UpdateProfileRequest {
name?: string;
bio?: string;
avatar?: string;
}
export interface UpdateProfileResponse {
profile: UserProfile;
message: string;
}Step 2: Create Validation Schema
Create Zod schemas in apps/web/lib/schemas/profile.ts:
// apps/web/lib/schemas/profile.ts
import { z } from "zod";
export const updateProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").optional(),
bio: z.string().max(500, "Bio must be less than 500 characters").optional(),
avatar: z.string().url("Invalid avatar URL").optional(),
});
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;Step 3: Create Repository Method
Add database queries in packages/server/web/src/repository/profileRepository.ts:
// packages/server/web/src/repository/profileRepository.ts
import { BaseRepository } from "@uwdsc/server/core/repository/baseRepository";
export class ProfileRepository extends BaseRepository {
/**
* Find user profile by user ID
*/
async findProfileByUserId(userId: string) {
const result = await this.pool.query(
`SELECT id, email, name, bio, avatar, created_at as "createdAt"
FROM users
WHERE id = $1`,
[userId]
);
return result.rows[0] || null;
}
/**
* Update user profile
*/
async updateProfile(userId: string, data: {
name?: string;
bio?: string;
avatar?: string;
}) {
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (data.name !== undefined) {
updates.push(`name = $${paramIndex++}`);
values.push(data.name);
}
if (data.bio !== undefined) {
updates.push(`bio = $${paramIndex++}`);
values.push(data.bio);
}
if (data.avatar !== undefined) {
updates.push(`avatar = $${paramIndex++}`);
values.push(data.avatar);
}
if (updates.length === 0) {
return this.findProfileByUserId(userId);
}
updates.push(`updated_at = NOW()`);
values.push(userId);
const result = await this.pool.query(
`UPDATE users
SET ${updates.join(", ")}
WHERE id = $${paramIndex}
RETURNING id, email, name, bio, avatar, created_at as "createdAt"`,
values
);
return result.rows[0];
}
/**
* Check if profile exists
*/
async profileExists(userId: string): Promise<boolean> {
const result = await this.pool.query(
"SELECT COUNT(*) as count FROM users WHERE id = $1",
[userId]
);
return parseInt(result.rows[0].count) > 0;
}
}Step 4: Create Service Method
Add business logic in packages/server/web/src/services/profileService.ts:
// packages/server/web/src/services/profileService.ts
import { ProfileRepository } from "../repository/profileRepository";
import { ApiError } from "@uwdsc/server/core/utils/errors";
export class ProfileService {
private repository: ProfileRepository;
constructor() {
this.repository = new ProfileRepository();
}
/**
* Get user profile
*/
async getProfile(userId: string) {
const profile = await this.repository.findProfileByUserId(userId);
if (!profile) {
throw new ApiError("Profile not found", 404, "PROFILE_NOT_FOUND");
}
return profile;
}
/**
* Update user profile
*/
async updateProfile(
userId: string,
data: {
name?: string;
bio?: string;
avatar?: string;
}
) {
// Check if profile exists
const exists = await this.repository.profileExists(userId);
if (!exists) {
throw new ApiError("Profile not found", 404, "PROFILE_NOT_FOUND");
}
// Validate avatar URL if provided
if (data.avatar) {
await this.validateAvatarUrl(data.avatar);
}
// Update profile
const updated = await this.repository.updateProfile(userId, data);
return {
profile: updated,
message: "Profile updated successfully",
};
}
/**
* Validate avatar URL
*/
private async validateAvatarUrl(url: string) {
// Check if URL is accessible
try {
const response = await fetch(url, { method: "HEAD" });
if (!response.ok) {
throw new Error("Invalid avatar URL");
}
} catch (error) {
throw new ApiError(
"Avatar URL is not accessible",
400,
"INVALID_AVATAR_URL"
);
}
}
}Step 5: Create API Routes
Create Next.js API routes in apps/web/app/api/profile/:
GET Profile Route
// apps/web/app/api/profile/route.ts
import { NextRequest, NextResponse } from "next/server";
import { ProfileService } from "@uwdsc/server/web/services/profileService";
import { getCurrentUser } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
// Get current user from session
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Get profile
const profileService = new ProfileService();
const profile = await profileService.getProfile(user.id);
return NextResponse.json(profile);
} catch (error) {
console.error("Get profile error:", error);
return NextResponse.json(
{ error: error.message || "Failed to fetch profile" },
{ status: error.statusCode || 500 }
);
}
}UPDATE Profile Route
// apps/web/app/api/profile/route.ts (continued)
import { updateProfileSchema } from "@/lib/schemas/profile";
export async function PATCH(request: NextRequest) {
try {
// Get current user
const user = await getCurrentUser();
if (!user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Parse and validate request body
const body = await request.json();
const validated = updateProfileSchema.parse(body);
// Update profile
const profileService = new ProfileService();
const result = await profileService.updateProfile(user.id, validated);
return NextResponse.json(result);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.errors },
{ status: 400 }
);
}
console.error("Update profile error:", error);
return NextResponse.json(
{ error: error.message || "Failed to update profile" },
{ status: error.statusCode || 500 }
);
}
}Step 6: Create Client API Functions
Create client-side API wrappers in apps/web/lib/api/profile.ts:
// apps/web/lib/api/profile.ts
import type { UserProfile, UpdateProfileRequest, UpdateProfileResponse } from "@/types/api";
/**
* Get current user profile
*/
export async function getProfile(): Promise<UserProfile> {
const response = await fetch("/api/profile");
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to fetch profile");
}
return response.json();
}
/**
* Update user profile
*/
export async function updateProfile(
data: UpdateProfileRequest
): Promise<UpdateProfileResponse> {
const response = await fetch("/api/profile", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Failed to update profile");
}
return response.json();
}Step 7: Use in Components
Now use the API in your React components:
// apps/web/app/profile/page.tsx
"use client";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { getProfile, updateProfile } from "@/lib/api/profile";
import { updateProfileSchema } from "@/lib/schemas/profile";
import { Button, Input, Textarea } from "@uwdsc/ui";
export default function ProfilePage() {
const [profile, setProfile] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(updateProfileSchema),
});
// Load profile
useEffect(() => {
getProfile().then(setProfile);
}, []);
// Handle form submission
const onSubmit = async (data) => {
setIsLoading(true);
try {
const result = await updateProfile(data);
setProfile(result.profile);
alert(result.message);
} catch (error) {
alert(error.message);
} finally {
setIsLoading(false);
}
};
if (!profile) return <div>Loading...</div>;
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Name</label>
<Input
{...register("name")}
defaultValue={profile.name}
placeholder="Your name"
/>
{errors.name && <p>{errors.name.message}</p>}
</div>
<div>
<label>Bio</label>
<Textarea
{...register("bio")}
defaultValue={profile.bio}
placeholder="Tell us about yourself"
/>
{errors.bio && <p>{errors.bio.message}</p>}
</div>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Saving..." : "Save Profile"}
</Button>
</form>
);
}Testing Your API
1. Manual Testing
# Start dev server
pnpm dev:web
# Test GET endpoint
curl http://localhost:3000/api/profile
# Test PATCH endpoint
curl -X PATCH http://localhost:3000/api/profile \
-H "Content-Type: application/json" \
-d '{"name":"John Doe","bio":"Software developer"}'2. Unit Testing Service
// packages/server/web/src/services/__tests__/profileService.test.ts
import { ProfileService } from "../profileService";
describe("ProfileService", () => {
let service: ProfileService;
beforeEach(() => {
service = new ProfileService();
});
it("should get user profile", async () => {
const profile = await service.getProfile("user-id");
expect(profile).toBeDefined();
expect(profile.id).toBe("user-id");
});
it("should update profile", async () => {
const result = await service.updateProfile("user-id", {
name: "New Name",
});
expect(result.profile.name).toBe("New Name");
});
});Common Patterns
Pagination
// Repository
async findManyWithPagination(page: number, limit: number) {
const offset = (page - 1) * limit;
const [itemsResult, countResult] = await Promise.all([
this.pool.query(
`SELECT * FROM items
LIMIT $1 OFFSET $2`,
[limit, offset]
),
this.pool.query("SELECT COUNT(*) as total FROM items"),
]);
return {
items: itemsResult.rows,
total: parseInt(countResult.rows[0].total),
page,
totalPages: Math.ceil(parseInt(countResult.rows[0].total) / limit),
};
}Filtering
// Repository
async findFiltered(filters: {
search?: string;
category?: string;
status?: string;
}) {
const conditions: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (filters.search) {
conditions.push(
`(name ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
);
values.push(`%${filters.search}%`);
paramIndex++;
}
if (filters.category) {
conditions.push(`category = $${paramIndex++}`);
values.push(filters.category);
}
if (filters.status) {
conditions.push(`status = $${paramIndex++}`);
values.push(filters.status);
}
const whereClause = conditions.length > 0
? `WHERE ${conditions.join(" AND ")}`
: "";
const result = await this.pool.query(
`SELECT * FROM items ${whereClause}`,
values
);
return result.rows;
}File Upload
// Service
async uploadAvatar(userId: string, file: File) {
// Upload to storage (Supabase)
const { data, error } = await supabase.storage
.from("avatars")
.upload(`${userId}/avatar.png`, file);
if (error) throw new ApiError("Upload failed", 500);
// Update profile with new avatar URL
const url = supabase.storage.from("avatars").getPublicUrl(data.path);
return this.repository.updateProfile(userId, { avatar: url.data.publicUrl });
}Best Practices
âś… Do
- Validate all inputs with Zod
- Use TypeScript for type safety
- Handle errors appropriately
- Check authentication/authorization
- Use transactions for multi-step operations
- Add JSDoc comments
- Write unit tests
❌ Don’t
- Skip input validation
- Expose internal errors to clients
- Put business logic in API routes
- Access database directly from routes
- Return sensitive data
Next Steps
- Database Setup - Working with db-mate and pg
- Development Tips - Improve workflow