agentleFS
Piyush8296Piyush8296/claude-workspace/.claude/skills/api-layer/SKILL.md

api-layer

Skill2 starsChanged 6 months agoMITView on GitHub

API client architecture for React and Next.js — typed fetch wrappers, interceptors, retry with backoff, request cancellation, pagination patterns, file uploads, and Next.js API route handlers. Use when building API clients, data fetching layers, or integrating with backends.

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Never scatter raw fetch() calls across components.

    line 10

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

API Layer

Architecture

Never scatter raw fetch() calls across components. Build a typed API client that handles auth, errors, retries, and cancellation in one place.

Component → TanStack Query hook → API client function → fetchClient (interceptors) → fetch()

Typed Fetch Client

// lib/api/client.ts
import { env } from '@/lib/env';

interface FetchOptions extends Omit<RequestInit, 'body'> {
  body?: unknown;
  params?: Record<string, string | number | undefined>;
  timeout?: number;
}

class ApiError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    public data: unknown,
    public url: string,
  ) {
    super(`${status} ${statusText}: ${url}`);
    this.name = 'ApiError';
  }

  get isUnauthorized() { return this.status === 401; }
  get isForbidden() { return this.status === 403; }
  get isNotFound() { return this.status === 404; }
  get isRateLimit() { return this.status === 429; }
  get isServerError() { return this.status >= 500; }
}

async function fetchClient<T>(path: string, options: FetchOptions = {}): Promise<T> {
  const { body, params, timeout = 10_000, headers: customHeaders, ...rest } = options;

  // Build URL with query params
  const url = new URL(path, env.API_BASE_URL);
  if (params) {
    Object.entries(params).forEach(([k, v]) => {
      if (v !== undefined) url.searchParams.set(k, String(v));
    });
  }

  // Timeout via AbortController
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout);

  try {
    const res = await fetch(url.toString(), {
      ...rest,
      signal: controller.signal,
      headers: {
        'Content-Type': 'application/json',
        ...customHeaders,
      },
      body: body ? JSON.stringify(body) : undefined,
    });

    if (!res.ok) {
      const data = await res.json().catch(() => null);
      throw new ApiError(res.status, res.statusText, data, url.toString());
    }

    // Handle 204 No Content
    if (res.status === 204) return undefined as T;

    return res.json() as Promise<T>;
  } finally {
    clearTimeout(timer);
  }
}

export { fetchClient, ApiError };
export type { FetchOptions };

Auth Interceptor

// lib/api/auth-interceptor.ts
import { getSession } from '@/lib/auth';
import { fetchClient, ApiError } from './client';
import type { FetchOptions } from './client';

let isRefreshing = false;
let refreshPromise: Promise<string> | null = null;

async function getValidToken(): Promise<string | null> {
  const session = await getSession();
  if (!session?.accessToken) return null;

  // If token is expired and not already refreshing
  if (session.isExpired && !isRefreshing) {
    isRefreshing = true;
    refreshPromise = refreshToken(session.refreshToken)
      .finally(() => { isRefreshing = false; refreshPromise = null; });
  }

  // If a refresh is in progress, wait for it
  if (refreshPromise) return refreshPromise;

  return session.accessToken;
}

export async function authedFetch<T>(path: string, options: FetchOptions = {}): Promise<T> {
  const token = await getValidToken();

  return fetchClient<T>(path, {
    ...options,
    headers: {
      ...options.headers,
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
  });
}

Retry with Exponential Backoff

// lib/api/retry.ts
interface RetryConfig {
  maxRetries?: number;
  baseDelay?: number;
  retryOn?: (error: unknown) => boolean;
}

export async function withRetry<T>(
  fn: () => Promise<T>,
  config: RetryConfig = {},
): Promise<T> {
  const { maxRetries = 3, baseDelay = 1000, retryOn = defaultRetryCondition } = config;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries || !retryOn(error)) throw error;

      const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 500;
      await new Promise((r) => setTimeout(r, delay));
    }
  }

  throw new Error('Retry exhausted'); // Unreachable, satisfies TS
}

function defaultRetryCondition(error: unknown): boolean {
  if (error instanceof ApiError) {
    return error.isServerError || error.isRateLimit;
  }
  return error instanceof TypeError; // Network error
}

Request Cancellation

// Cancel on component unmount (TanStack Query does this automatically)
// For manual fetch calls:

function useApiCall() {
  const controllerRef = useRef<AbortController | null>(null);

  const execute = useCallback(async (url: string) => {
    // Cancel previous in-flight request
    controllerRef.current?.abort();
    controllerRef.current = new AbortController();

    return fetchClient(url, { signal: controllerRef.current.signal });
  }, []);

  // Cancel on unmount
  useEffect(() => () => { controllerRef.current?.abort(); }, []);

  return execute;
}

Pagination Patterns

// Cursor-based (preferred for infinite scroll)
interface CursorPage<T> {
  items: T[];
  nextCursor: string | null;
  hasMore: boolean;
}

function useInfiniteItems() {
  return useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam }) =>
      authedFetch<CursorPage<Item>>('/api/items', { params: { cursor: pageParam, limit: 20 } }),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (last) => last.hasMore ? last.nextCursor : undefined,
  });
}

// Offset-based (for numbered pages)
interface OffsetPage<T> {
  items: T[];
  total: number;
  page: number;
  pageSize: number;
}

function usePagedItems(page: number) {
  return useQuery({
    queryKey: ['items', { page }],
    queryFn: () => authedFetch<OffsetPage<Item>>('/api/items', { params: { page, pageSize: 20 } }),
  });
}

Next.js API Route Handlers

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
});

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const data = createUserSchema.parse(body);  // Validate input
    const user = await db.users.create({ data });
    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json({ errors: error.flatten() }, { status: 400 });
    }
    console.error('POST /api/users failed:', error);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Anti-Patterns

BadGoodWhy
Raw fetch() in componentsTyped fetchClient in api layerCentralized error handling, auth, retry
No timeout on requestsAbortController with timeoutPrevents hung requests
Silent retry foreverRetry with max attempts + backoffPrevents thundering herd
any for API responsesZod schema validationRuntime type safety
Ignoring AbortErrorif (error.name !== 'AbortError') throw errorDon't show error on cancel

Integration with Other Skills

  • state-management: TanStack Query hooks call api-layer functions
  • error-handling: ApiError class feeds into error boundaries
  • auth-patterns: Auth interceptor for token management
  • security-audit: Input validation, CSRF, header security

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.