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, 1);

const articles = await strapi.documents('api::article.article').findMany({
filters: {
publishedAt: {
$gte: startDate.toISOString(),
$lt: 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,
},
}

auth: false bypasses Strapi's Content API authentication for that route. For generated content-type routes that still use the Users & Permissions plugin, grant access in the relevant role instead of disabling auth in custom route config.


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),
});

// Sanitize each type's items against its own content-type schema and the
// caller's auth context before returning - findMany() does not do this for you.
for (const group of Object.values(results.data)) {
const schema = strapi.contentType(group.uid);
group.items = await strapi.contentAPI.sanitize.output(group.items, schema, {
auth: ctx.state.auth,
});
}

return results;
},
};

Service​

// src/api/search/services/search.js
const MAX_PAGE_SIZE = 100;

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

if (type && !searchableTypes[type]) {
return {
data: {},
query,
error: `Unsupported search type: ${type}`,
};
}

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

// Reject/clamp untrusted page and pageSize input before it reaches the query.
const safePage = Number.isInteger(page) && page > 0 ? page : 1;
const safePageSize = Number.isInteger(pageSize) && pageSize > 0
? Math.min(pageSize, MAX_PAGE_SIZE)
: 25;

const results = {};
const start = (safePage - 1) * safePageSize;
const filters = {
$or: [
{ title: { $containsi: query } },
{ description: { $containsi: query } },
],
};

for (const [key, uid] of Object.entries(typesToSearch)) {
const [items, total] = await Promise.all([
strapi.documents(uid).findMany({
filters,
fields: ['title', 'slug', 'description'],
status: 'published',
start,
limit: safePageSize,
}),
strapi.documents(uid).count({ filters, status: 'published' }),
]);

results[key] = {
uid,
items,
pagination: {
page: safePage,
pageSize: safePageSize,
pageCount: Math.ceil(total / safePageSize),
total,
},
};
}

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
Generated route lacks role accessUsers & Permissions returns 403Enable 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​