Skip to main content

Custom Routes and Endpoints

Strapi auto-generates REST routes for every content type (find, findOne, create, update, delete). Custom routes let you add new endpoints, change URL patterns, or restrict access to existing ones.

Core router vs custom router

ConceptCore RouterCustom Router
Created bycreateCoreRouter() factoryManual route definition
ActionsMaps to default controller CRUDMaps to any controller action
Filesrc/api/[name]/routes/[name].jssrc/api/[name]/routes/01-custom.js
Override configUse config object to attach middleware/policiesUse per-route config object

Configuring the core router

The core router already handles the five standard CRUD routes. You can customise each action's config:

// src/api/article/routes/article.js
const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter('api::article.article', {
config: {
// Make find (list) public
find: {
auth: false,
middlewares: ['api::article.default-populate'],
policies: [],
},
// Make findOne public
findOne: {
auth: false,
},
// Restrict create to authenticated editors
create: {
policies: [
{
name: 'global::has-role',
config: { roles: ['editor', 'admin'] },
},
],
},
// Only owners can update
update: {
middlewares: ['api::article.is-owner'],
},
// Only admins can delete
delete: {
policies: [
{
name: 'global::has-role',
config: { roles: ['admin'] },
},
],
},
},
});

Creating custom routes

Custom routes expose new endpoints that map to controller actions.

Basic custom route

// src/api/article/routes/01-custom-article.js
module.exports = {
routes: [
{
method: 'GET',
path: '/articles/featured',
handler: 'api::article.article.findFeatured',
config: {
auth: false, // public endpoint
},
},
{
method: 'POST',
path: '/articles/:id/like',
handler: 'api::article.article.like',
config: {
policies: [],
middlewares: [],
},
},
],
};

Route files load in alphabetical order. Prefix custom routes with 01- so they load before the core router and avoid being shadowed by wildcard patterns.

URL parameters

module.exports = {
routes: [
{
method: 'GET',
path: '/articles/by-author/:authorId',
handler: 'api::article.article.findByAuthor',
},
{
method: 'GET',
path: '/articles/:year/:month',
handler: 'api::article.article.findByDate',
},
],
};

Parameters are available in the controller via ctx.params:

// Controller
async findByAuthor(ctx) {
const { authorId } = ctx.params;
const articles = await strapi.documents('api::article.article').findMany({
filters: { author: { documentId: authorId } },
populate: ['cover'],
status: 'published',
});
return { data: articles };
},

async findByDate(ctx) {
const { year, month } = ctx.params;
const startDate = new Date(year, month - 1, 1);
const endDate = new Date(year, month, 0);

const articles = await strapi.documents('api::article.article').findMany({
filters: {
publishedAt: {
$gte: startDate.toISOString(),
$lte: endDate.toISOString(),
},
},
});
return { data: articles };
},

Regex-constrained parameters

module.exports = {
routes: [
{
method: 'GET',
// Only match numeric IDs
path: '/articles/:id(\\d+)',
handler: 'api::article.article.findOne',
},
{
method: 'GET',
// Only match lowercase slug patterns
path: '/articles/:slug([a-z0-9-]+)',
handler: 'api::article.article.findBySlug',
},
],
};

Public routes (no authentication)

Set auth: false in the route config:

{
method: 'GET',
path: '/articles/sitemap',
handler: 'api::article.article.sitemap',
config: {
auth: false,
},
}

You must also enable the route in the Users & Permissions plugin under Public role permissions.


Route-level middleware and policies

Attach middleware and/or policies directly to a route:

{
method: 'GET',
path: '/articles/premium',
handler: 'api::article.article.findPremium',
config: {
policies: [
'global::is-authenticated',
{
name: 'global::has-role',
config: { roles: ['premium', 'admin'] },
},
],
middlewares: [
'api::article.response-time',
// Inline middleware
(ctx, next) => {
ctx.set('X-Content-Type', 'premium');
return next();
},
],
},
}

Example: full custom API

A complete example of a "search" endpoint with its own route, controller, and service:

Route

// src/api/search/routes/search.js
module.exports = {
routes: [
{
method: 'GET',
path: '/search',
handler: 'api::search.search.search',
config: {
auth: false,
policies: [],
middlewares: [],
},
},
],
};

Controller

// src/api/search/controllers/search.js
module.exports = {
async search(ctx) {
const { q, type, page = 1, pageSize = 20 } = ctx.query;

if (!q || q.length < 2) {
return ctx.badRequest('Search query must be at least 2 characters');
}

const results = await strapi.service('api::search.search').search({
query: q,
type,
page: Number(page),
pageSize: Number(pageSize),
});

return results;
},
};

Service

// src/api/search/services/search.js
module.exports = ({ strapi }) => ({
async search({ query, type, page, pageSize }) {
const searchableTypes = {
articles: 'api::article.article',
pages: 'api::page.page',
products: 'api::product.product',
};

const typesToSearch = type
? { [type]: searchableTypes[type] }
: searchableTypes;

const results = {};

for (const [key, uid] of Object.entries(typesToSearch)) {
const { results: items, pagination } = await strapi
.documents(uid)
.findMany({
filters: {
$or: [
{ title: { $containsi: query } },
{ description: { $containsi: query } },
],
},
fields: ['title', 'slug', 'description'],
status: 'published',
page,
pageSize,
});

results[key] = { items, pagination };
}

return { data: results, query };
},
});

TypeScript routes

// src/api/article/routes/01-custom-article.ts
export default {
routes: [
{
method: 'GET',
path: '/articles/featured',
handler: 'api::article.article.findFeatured',
config: {
auth: false,
},
},
],
};

Common pitfalls

PitfallProblemFix
Custom route shadowed by core routerCore wildcard :id matches your custom pathPrefix custom route file with 01-
handler string typoRoute returns 404Use the full api::name.controller.action format
Public route but no role permission403 Forbidden despite auth: falseEnable the route in Public role settings
Duplicate method+pathOnly the first route winsEnsure unique method+path combinations
Missing controller action500 error on requestCreate the corresponding controller method

See also