Skip to main content

Chapter 25: API Design

The API layer is the contract between your frontend and backend. This chapter covers the architectural options, our recommendation, and patterns for type-safe API communication.

Choosing an API Architecture​

ApproachBest ForTrade-offs
REST + Effect SchemaMost applications, public APIsSimple, widely understood, needs schema discipline
tRPCTypeScript monorepos, internal APIsZero-codegen type safety, TypeScript-only
Server FunctionsTanStack Start full-stack appsTightest integration, framework-coupled
GraphQLComplex data relationships, multi-clientPowerful but complex, needs resolver layer

Our Recommendation: REST + Effect Schema​

For our stack, we recommend REST endpoints with Effect Schema for request/response validation. This gives us:

  1. Type safety through shared schemas (frontend + backend import the same schemas)
  2. Runtime validation at the API boundary
  3. Simplicity — REST is universally understood
  4. Flexibility — works with any client, not just TypeScript

If you are using TanStack Start, server functions are an excellent alternative that provides even tighter type safety.

API Conventions​

URL Structure​

GET    /api/projects                    # List projects
POST /api/projects # Create project
GET /api/projects/:id # Get project
PATCH /api/projects/:id # Update project
DELETE /api/projects/:id # Delete project
GET /api/projects/:id/tasks # List tasks for project
POST /api/projects/:id/tasks # Create task in project

Response Format​

// Successful response
{
"data": { /* entity or array */ },
"meta": { // For paginated responses
"total": 100,
"page": 1,
"pageSize": 20,
"totalPages": 5
}
}

// Error response
{
"error": {
"code": "PROJECT_NOT_FOUND", // Machine-readable error code
"message": "Project not found", // Human-readable message
"details": { // Additional context
"projectId": "proj-123"
}
}
}

Standard Response Schemas​

// packages/shared/src/schemas/api-schemas.ts
import { Schema } from "effect";

export const ApiError = Schema.Struct({
code: Schema.String,
message: Schema.String,
details: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
});

export const ApiResponse = <A, I, R>(dataSchema: Schema.Schema<A, I, R>) =>
Schema.Struct({
data: dataSchema,
});

export const PaginatedResponse = <A, I, R>(itemSchema: Schema.Schema<A, I, R>) =>
Schema.Struct({
data: Schema.Array(itemSchema),
meta: Schema.Struct({
total: Schema.Number,
page: Schema.Number,
pageSize: Schema.Number,
totalPages: Schema.Number,
}),
});

API Client with Effect​

// shared/services/api-client.ts
import { Effect, Schema } from "effect";
import { useAuthStore } from "@/features/auth/stores/auth-store";

class ApiError extends Data.TaggedError("ApiError")<{
readonly status: number;
readonly code: string;
readonly message: string;
}> {}

class NetworkError extends Data.TaggedError("NetworkError")<{
readonly cause: unknown;
}> {}

function apiRequest<A>(options: {
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
path: string;
body?: unknown;
schema: Schema.Schema<A>;
}): Effect.Effect<A, ApiError | NetworkError> {
return Effect.gen(function* () {
const baseUrl = import.meta.env.VITE_API_BASE_URL;
const token = useAuthStore.getState().token;

const response = yield* Effect.tryPromise({
try: () =>
fetch(`${baseUrl}${options.path}`, {
method: options.method,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
}),
catch: (cause) => new NetworkError({ cause }),
});

if (!response.ok) {
const errorBody = yield* Effect.tryPromise({
try: () => response.json(),
catch: () => ({ code: "UNKNOWN", message: "Unknown error" }),
});
yield* new ApiError({
status: response.status,
code: errorBody.error?.code ?? "UNKNOWN",
message: errorBody.error?.message ?? `HTTP ${response.status}`,
});
}

const json = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new NetworkError({ cause }),
});

return yield* Schema.decodeUnknown(options.schema)(json.data ?? json);
});
}

// Convenience methods
export const api = {
get: <A>(path: string, schema: Schema.Schema<A>) =>
apiRequest({ method: "GET", path, schema }),

post: <A>(path: string, body: unknown, schema: Schema.Schema<A>) =>
apiRequest({ method: "POST", path, body, schema }),

patch: <A>(path: string, body: unknown, schema: Schema.Schema<A>) =>
apiRequest({ method: "PATCH", path, body, schema }),

delete: (path: string) =>
apiRequest({ method: "DELETE", path, schema: Schema.Void }),
};

Server Function Alternative (TanStack Start)​

If using TanStack Start, server functions provide zero-configuration type safety:

// src/server/functions/projects.ts
import { createServerFn } from "@tanstack/start";

export const listProjects = createServerFn({ method: "GET" })
.validator((input: { orgId: string; page?: number }) => input)
.handler(async ({ data }) => {
const projects = await prisma.project.findMany({
where: { organizationId: data.orgId },
skip: ((data.page ?? 1) - 1) * 20,
take: 20,
});
return projects;
});

// Client usage — fully typed, no API client needed
const projects = await listProjects({ data: { orgId: "org-1", page: 1 } });

Summary​

  • ✅ REST + Effect Schema for most applications — simple, type-safe, universal
  • ✅ Server functions for TanStack Start apps — tightest integration
  • ✅ Consistent response format with data/error envelope
  • ✅ Shared schemas between frontend and backend for type safety
  • ✅ Effect-based API client with typed errors and schema validation