Skip to content

[Docs] Custom route Endpoints interface — full documentation with examples #58

@WardenCommander

Description

@WardenCommander

[Docs] Custom route Endpoints interface — full documentation with examples

Problem

The Endpoints interface pattern for typing custom Strapi controller routes is
undocumented. It is not mentioned anywhere in the official strapi-typed-client
docs. Developers who need custom routes have to:

  1. Read the generator source code to discover the interface exists
  2. Guess the supported shape by trial and error
  3. Hope the codegen picks up their controller file correctly

This is a high-friction experience for something that is used in virtually every
real Strapi project — custom business logic routes are the norm, not the exception.


What needs to be documented

The Endpoints interface

The generator scans Strapi controller files for an exported Endpoints interface.
Each key maps to a custom route method. The generator reads this interface and emits
typed methods on the collection's client class.

Basic structure:

// src/api/{collection}/controllers/{collection}.ts

export interface Endpoints {
  methodName: {
    // shape of ctx.request.body
    body?: { ... };

    // shape of ctx.request.query
    query?: { ... };

    // URL params (e.g. :documentId in the route path)
    params?: { ... };

    // shape of the response body (what ctx.body.data is set to)
    response: { ... };
  };
}

All fields except response are optional depending on the HTTP method and route.


Examples

Example 1 — Simple POST with body and response

The most common pattern: a POST action that takes a documentId and returns a
success/error envelope. Matches the connection controller pattern.

// controller
export interface Endpoints {
  sync: {
    body: {
      documentId: string;
    };
    response: {
      success: boolean;
      errors?: Array<{ message: string }>;
    };
  };
}

Generated client method:

// Generated on ConnectionAPI
async sync(data: { documentId: string }): Promise<{
  success: boolean;
  errors?: Array<{ message: string }>;
}>;

Usage:

const result = await client.connections.sync({ documentId: "abc123" });
if (!result.success) {
  result.errors?.forEach(e => console.error(e.message));
}

Example 2 — GET with URL params

A route that reads from the URL path, no body.

// Route: GET /api/reservations/invoice/:documentId
export interface Endpoints {
  invoice: {
    params: {
      documentId: string;
    };
    response: {
      success: boolean;
      html: string | null;
      filename: string | null;
      error?: string;
    };
  };
}

Generated client method:

async invoice(documentId: string): Promise<{
  success: boolean;
  html: string | null;
  filename: string | null;
  error?: string;
}>;

Usage:

const invoice = await client.reservations.invoice(documentId);
if (invoice.success && invoice.html) {
  // render invoice
}

Example 3 — GET with query parameters

A route that accepts filters or options as query string parameters.

// Route: GET /api/units/availability
export interface Endpoints {
  availability: {
    query: {
      dateFrom: string;  // ISO date string
      dateTo: string;
      locationId?: string;
    };
    response: Array<{
      documentId: string;
      title: string;
      available: boolean;
    }>;
  };
}

Generated client method:

async availability(query: {
  dateFrom: string;
  dateTo: string;
  locationId?: string;
}): Promise<Array<{ documentId: string; title: string; available: boolean }>>;

Usage:

const units = await client.units.availability({
  dateFrom: "2024-06-01",
  dateTo:   "2024-06-07",
});

Example 4 — Response with a related entity

When the response includes a fully populated Strapi entity, reference the generated
entity type directly in the response shape.

import type { Reservation } from "../../../types"; // generated types

// Route: POST /api/reservations/confirm
export interface Endpoints {
  confirm: {
    body: {
      documentId: string;
      ownerNote?: string;
    };
    response: {
      success: boolean;
      reservation: Reservation; // full entity returned
    };
  };
}

Generated client method:

async confirm(data: {
  documentId: string;
  ownerNote?: string;
}): Promise<{
  success: boolean;
  reservation: Reservation;
}>;

Example 5 — Response with a media file

When the route returns an uploaded file (e.g. a generated PDF stored via the upload
plugin), use the MediaFile type from the generated base types.

import type { MediaFile } from "../../../types";

// Route: POST /api/reservations/generate-pdf
export interface Endpoints {
  generatePdf: {
    body: {
      documentId: string;
      locale?: string;
    };
    response: {
      success: boolean;
      file: MediaFile | null;
    };
  };
}

Generated client method:

async generatePdf(data: {
  documentId: string;
  locale?: string;
}): Promise<{
  success: boolean;
  file: MediaFile | null;
}>;

Example 6 — File upload (FormData body)

When the route accepts a file upload, mark the body as FormData. The generator
will type the method to accept FormData instead of a plain object.

// Route: POST /api/units/import
export interface Endpoints {
  import: {
    body: FormData;
    response: {
      imported: number;
      skipped: number;
      errors: Array<{ row: number; message: string }>;
    };
  };
}

Generated client method:

async import(data: FormData): Promise<{
  imported: number;
  skipped: number;
  errors: Array<{ row: number; message: string }>;
}>;

Usage:

const form = new FormData();
form.append("file", fileBlob, "units.csv");

const result = await client.units.import(form);
console.log(`Imported: ${result.imported}, skipped: ${result.skipped}`);

Example 7 — Multiple methods on one controller

A single controller can expose multiple custom routes. All methods are declared
in the same Endpoints interface:

// Route: POST /api/connections/sync
// Route: POST /api/connections/sync-unit
// Route: POST /api/connections/request-sync
// Route: POST /api/connections/request-unit
export interface Endpoints {
  sync: {
    body:     { documentId: string };
    response: { success: boolean; errors?: Array<{ message: string }> };
  };

  syncUnit: {
    body:     { documentId: string };
    response: { success: boolean; errors?: Array<{ message: string }> };
  };

  requestSync: {
    body:     { documentId: string };
    response: { success: boolean; errors?: Array<{ message: string }> };
  };

  requestUnit: {
    body:     { documentId: string };
    response: { success: boolean; errors?: Array<{ message: string }> };
  };
}

Example 8 — Standalone controller (no collection)

For routes that don't belong to a content type (e.g. a custom plugin route or
a utility endpoint), the Endpoints interface is placed on a standalone controller.
The generator creates a separate API class on the client rather than extending a
CollectionAPI.

// src/api/ical/controllers/ical.ts
export interface Endpoints {
  findOne: {
    params:   { documentId: string };
    response: string; // plain iCal text
  };
}

Generated client property:

// StrapiClient
ical: IcalAPI;

// IcalAPI
async findOne(documentId: string): Promise<string>;

How the generator resolves Endpoints

Controller file location Generated client property
src/api/{name}/controllers/{name}.ts client.{pluralName} — extends CollectionAPI
src/api/{name}/controllers/{name}.ts (single type) client.{name} — extends SingleTypeAPI
Any controller without a matching content type Standalone class — separate property on StrapiClient

The generator matches the controller file to a schema by comparing the content
type UID (api::{name}) declared in factories.createCoreController(...). If no
matching schema exists, a standalone API class is created.


Namespace types for complex responses

For controllers with complex or reused response shapes, the recommended pattern
is to export a TypeScript namespace alongside the Endpoints interface. The
generator picks this up and exposes it as a typed namespace on the client.

// Recommended for complex response types
export namespace ConnectionAPI {
  export type SyncResponse = {
    success: boolean;
    errors?: Array<{ message: string }>;
    processedAt?: string;
  };
}

export interface Endpoints {
  sync: {
    body:     { documentId: string };
    response: ConnectionAPI.SyncResponse; // reference the namespace type
  };
  syncUnit: {
    body:     { documentId: string };
    response: ConnectionAPI.SyncResponse; // reuse across methods
  };
}

Generated namespace on the client:

// Consumer code
import type { ConnectionAPI } from "@org/shared-types";

const result: ConnectionAPI.SyncResponse = await client.connections.sync({
  documentId: "abc123",
});

Strapi controller side — recommended structure

For reference, the full recommended controller file structure that the generator
expects:

import { factories } from "@strapi/strapi";

/**
 * Typed endpoint definitions for strapi-typed-client codegen.
 * Re-run `generate-types` after changing this interface.
 */
export interface Endpoints {
  myAction: {
    body:     { documentId: string };
    response: { success: boolean };
  };
}

export default factories.createCoreController("api::{plugin}.{name}", ({ strapi }) => ({
  myAction: async (ctx) => {
    const { documentId } = ctx.request.body;

    if (!documentId) {
      ctx.status = 400;
      ctx.body = { data: { success: false, errors: [{ message: "documentId is required" }] } };
      return;
    }

    const result = await strapi.service("api::{plugin}.{name}").myAction(documentId);

    ctx.status = result.success ? 200 : 400;
    ctx.body = { data: result };
  },
}));

Key points:

  • Always wrap the response in { data: result } — the client unwraps .data automatically
  • Endpoints must be a named export interface, not a type alias
  • The interface must be in the same file as the controller default export
  • Re-run generate-types after any change to Endpoints

Summary

Scenario body query params response
POST with documentId { documentId: string } { success: boolean }
GET with URL segment { documentId: string } entity or shape
GET with query string { field: type } array or shape
Related entity in response import entity type
Media file in response MediaFile
File upload FormData result shape
Multiple methods separate key per method in same interface
Standalone (no collection) same as above standalone class generated

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions