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
Define API types in the common package so they can be shared by apps and server packages. Add a new file under packages/common/src/types/api/ (e.g. profile.ts), then export it from packages/common/src/types/api/index.ts (e.g. export * from "./profile";):
// packages/common/src/types/api/profile.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;
}Import in apps and API code from @uwdsc/common/types:
import type { UserProfile, UpdateProfileRequest, UpdateProfileResponse } from "@uwdsc/common/types";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/core/src/repositories/. Repositories extend BaseRepository from @uwdsc/db/baseRepository and use this.sql (postgres.js):
// packages/server/core/src/repositories/ProfileRepository.ts
import { BaseRepository } from "@uwdsc/db/baseRepository";
export class ProfileRepository extends BaseRepository {
async findProfileByUserId(userId: string) {
const rows = await this.sql`
SELECT id, email, name, bio, avatar, created_at as "createdAt"
FROM profiles WHERE id = ${userId}
`;
return rows[0] ?? null;
}
async updateProfile(userId: string, data: { name?: string; bio?: string; avatar?: string }) {
// Build dynamic update with this.sql — see existing repos in core for patterns
const updated = await this.sql`...`;
return updated[0] ?? null;
}
async profileExists(userId: string): Promise<boolean> {
const rows = await this.sql`
SELECT 1 FROM profiles WHERE id = ${userId} LIMIT 1
`;
return rows.length > 0;
}
}Step 4: Create Service Method
Add business logic in packages/server/core/src/services/. For profile, the app already uses the shared profileService singleton from @uwdsc/core. If you add a new service:
// packages/server/core/src/services/ProfileService.ts
import { ProfileRepository } from "../repositories/ProfileRepository";
import { ApiError } from "@uwdsc/common/utils"; // or project error type
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/. Use the ApiResponse helper from @uwdsc/common/utils for consistent status codes and response body shapes.
Standardized responses (ApiResponse)
ApiResponse is a static helper class in @uwdsc/common/utils. Use it in API routes instead of building NextResponse.json() by hand:
| Method | Status | Use |
|---|---|---|
ApiResponse.ok(data) | 200 | Success with optional JSON body |
ApiResponse.badRequest(message?, error?) | 400 | Validation or client error |
ApiResponse.unauthorized(message?, error?) | 401 | Not authenticated |
ApiResponse.notFound(error?) | 404 | Resource not found |
ApiResponse.serverError(message?, error?) | 500 | Server/catch-all error; message can be Error or unknown |
ApiResponse.json(data, status?, init?) | custom | Custom status or body shape |
Response bodies follow a consistent shape: success uses the data you pass; errors use { error } or { error, message }. Client code can rely on error (and optionally message) when !response.ok.
import { ApiResponse } from "@uwdsc/common/utils";
// In a route:
return ApiResponse.ok({ profile, isComplete });
return ApiResponse.badRequest("Email is required");
return ApiResponse.notFound("Profile not found");
return ApiResponse.serverError(error, "Failed to fetch profile");GET Profile Route
// apps/web/app/api/profile/route.ts
import { NextRequest } from "next/server";
import { ApiResponse } from "@uwdsc/common/utils";
import { profileService } from "@uwdsc/core";
import { getCurrentUser } from "@/lib/auth";
export async function GET(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) return ApiResponse.unauthorized("Authentication required");
const profile = await profileService.getProfileByUserId(user.id);
if (!profile) return ApiResponse.notFound("Profile not found");
return ApiResponse.ok(profile);
} catch (error) {
console.error("Get profile error:", error);
return ApiResponse.serverError(error, "Failed to fetch profile");
}
}UPDATE Profile Route
// apps/web/app/api/profile/route.ts (continued)
import { z } from "zod";
import { updateProfileSchema } from "@/lib/schemas/profile";
export async function PATCH(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) return ApiResponse.unauthorized("Authentication required");
const body = await request.json();
const validated = updateProfileSchema.parse(body);
const result = await profileService.updateProfile(user.id, validated);
return ApiResponse.ok(result);
} catch (error) {
if (error instanceof z.ZodError) {
return ApiResponse.badRequest("Validation failed", JSON.stringify(error.errors));
}
console.error("Update profile error:", error);
return ApiResponse.serverError(error, "Failed to update profile");
}
}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 "@uwdsc/common/types";
/**
* 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>
);
}Manual testing
Start the app and call the API (e.g. with curl or the browser):
pnpm dev:web
# GET http://localhost:3000/api/profile
# PATCH http://localhost:3000/api/profile with JSON bodyFor pagination, filtering, and file uploads, follow patterns in existing repositories in packages/server/core (use this.sql tagged templates) and see ResumeService in @uwdsc/core for file uploads.
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
❌ 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 - Migrations and postgres.js
- Development Tips - Improve workflow