GuidesCreating API Endpoints

Creating API Endpoints

Step-by-step guide to creating new API endpoints following our architecture.

Overview

Creating a new API endpoint involves 5 steps:

  1. Define TypeScript types
  2. Create Zod validation schema
  3. Create repository method
  4. Create service method
  5. Create API route
  6. 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:

MethodStatusUse
ApiResponse.ok(data)200Success with optional JSON body
ApiResponse.badRequest(message?, error?)400Validation or client error
ApiResponse.unauthorized(message?, error?)401Not authenticated
ApiResponse.notFound(error?)404Resource not found
ApiResponse.serverError(message?, error?)500Server/catch-all error; message can be Error or unknown
ApiResponse.json(data, status?, init?)customCustom 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 body

For 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