Skip to main content

Routes, Policies & Middleware

In the previous chapter we created custom controller actions, but they are unreachable without routes. In this chapter we will map URLs to those actions, add access control with policies, and intercept requests with middleware.

The request lifecycle​

Every request in Strapi flows through a specific pipeline:

  1. Global middleware - runs on every request (CORS, body parser, security headers)
  2. Route middleware - runs on specific routes
  3. Policies - access control checks (pass or fail)
  4. Controller - handles the request and returns a response

Default routes​

Strapi auto-generates routes for every content type:

// src/api/post/routes/post.js
import { factories } from "@strapi/strapi";

export default factories.createCoreRouter("api::post.post");

This creates the standard REST routes:

MethodPathActionHandler
GET/api/postsfindpost.find
GET/api/posts/:documentIdfindOnepost.findOne
POST/api/postscreatepost.create
PUT/api/posts/:documentIdupdatepost.update
DELETE/api/posts/:documentIddeletepost.delete

Configuring core routes​

You can customize the core routes without replacing them:

// src/api/post/routes/post.js
import { factories } from "@strapi/strapi";

export default factories.createCoreRouter("api::post.post", {
config: {
find: {
// Attach middleware to the find route
middlewares: ["api::post.log-request"],
},
create: {
// Attach policies to the create route
policies: ["plugin::users-permissions.isAuthenticated"],
},
update: {
policies: ["plugin::users-permissions.isAuthenticated", "api::post.is-owner"],
},
delete: {
policies: ["plugin::users-permissions.isAuthenticated", "api::post.is-owner"],
},
},
});

Custom routes​

Custom controller actions (like findFeatured and findBySlug from chapter 7) need custom routes. Create a new route file:

// src/api/post/routes/custom-post.js
export default {
routes: [
{
method: "GET",
path: "/posts/featured",
handler: "api::post.post.findFeatured",
config: {
auth: false, // Public access
},
},
{
method: "GET",
path: "/posts/by-slug/:slug",
handler: "api::post.post.findBySlug",
config: {
auth: false,
},
},
{
method: "GET",
path: "/posts/:documentId/related",
handler: "api::post.post.findRelated",
config: {
auth: false,
},
},
],
};

Important: Custom route files must be placed in the routes/ directory of your content type API. The filename can be anything except post.js (which is reserved for the core router). A common convention is custom-post.js or 01-custom-post.js.

Route order matters​

Strapi matches routes in order. Custom routes are loaded before core routes if the filename sorts before the core route file. To ensure custom routes match first, prefix with a number:

src/api/post/routes/
├── 01-custom-post.js # Custom routes (matched first)
└── post.js # Core routes (matched second)

This matters when paths overlap. For example, /api/posts/featured could match the core findOne route ( /api/posts/:documentId) if core routes are checked first. Putting custom routes first prevents this.

Route configuration​

Each route accepts a config object:

{
method: "GET",
path: "/posts/featured",
handler: "api::post.post.findFeatured",
config: {
auth: false, // Public (no auth required)
policies: ["is-admin"], // Policy checks
middlewares: ["api::post.log-request"], // Middleware chain
},
}
OptionDefaultDescription
authtrueRequire authentication (false for public)
policies[]Array of policy names to apply
middlewares[]Array of middleware names to apply

URL parameters​

Use :param syntax for dynamic URL segments:

{
method: "GET",
path: "/posts/by-slug/:slug",
handler: "api::post.post.findBySlug",
}

Access in the controller:

async findBySlug(ctx) {
const { slug } = ctx.params; // "my-first-post"
// ...
}

Policies​

Policies are access control checks that run before the controller. They either pass (allow the request) or fail ( return 403 Forbidden).

Built-in policies​

Strapi provides built-in policies you can reference by name:

PolicyDescription
admin::isAuthenticatedAdminRequires admin authentication
plugin::users-permissions.isAuthenticatedRequires an authenticated Users & Permissions user

For ordinary generated Content API routes, role permissions and the auth config handle basic access. Use the Users & Permissions policy when a custom route explicitly needs an authenticated API user before running finer-grained custom policies.

Creating a custom policy​

Let's create an is-owner policy that ensures users can only update or delete their own posts.

Note: This example assumes you have linked your API users (from Users & Permissions) to your Author content type, for example by adding a one-to-one relation between Author and User. Without this link, user.id (the API user) and post.author.id (the Author entry) are unrelated. Adjust the comparison to match your data model.

// src/api/post/policies/is-owner.js
export default async (policyContext, config, { strapi }) => {
const user = policyContext.state.user;

// No user = not authenticated
if (!user) {
return false;
}

const { documentId } = policyContext.params;

// Fetch the post with its author (and the author's linked user)
const post = await strapi.documents("api::post.post").findOne({
documentId,
populate: { author: { populate: { user: { fields: ["id"] } } } },
});

if (!post) {
return false;
}

// Check if the authenticated user matches the author's linked user
if (post.author?.user?.id !== user.id) {
return false;
}

return true;
};

Attaching policies to routes​

Reference the policy in your route configuration:

// In core routes
export default factories.createCoreRouter("api::post.post", {
config: {
update: {
policies: ["api::post.is-owner"],
},
delete: {
policies: ["api::post.is-owner"],
},
},
});

// In custom routes
{
method: "PUT",
path: "/posts/:documentId",
handler: "api::post.post.update",
config: {
policies: ["api::post.is-owner"],
},
}

Global policies​

Policies shared across content types go in src/policies/:

// src/policies/is-authenticated.js
export default (policyContext, config, { strapi }) => {
if (policyContext.state.user) {
return true;
}

return false;
};

Reference global policies without the api:: prefix:

config: {
policies: ["global::is-authenticated"],
}

Policy configuration​

Policies can accept configuration:

// src/policies/rate-limit.js
export default (policyContext, config, { strapi }) => {
const maxRequests = config.max || 100;
const windowMs = config.window || 60000;

// Rate limiting logic here...
return true;
};

Use with configuration in routes:

config: {
policies: [
{
name: "global::rate-limit",
config: { max: 50, window: 60000 },
},
],
}

Middleware​

Middleware intercepts requests and responses. Unlike policies (which are binary pass/fail), middleware can transform the request, add headers, log data, or modify the response.

Creating route middleware​

// src/api/post/middlewares/log-request.js
export default (config, { strapi }) => {
return async (ctx, next) => {
const start = Date.now();

strapi.log.info(
`[POST API] ${ctx.method} ${ctx.url} - Start`
);

// Call the next middleware / controller
await next();

const duration = Date.now() - start;
strapi.log.info(
`[POST API] ${ctx.method} ${ctx.url} - ${ctx.status} (${duration}ms)`
);
};
};

Attaching route middleware​

Reference middleware in route configuration:

// Core routes
export default factories.createCoreRouter("api::post.post", {
config: {
find: {
middlewares: ["api::post.log-request"],
},
},
});

// Custom routes
{
method: "GET",
path: "/posts/featured",
handler: "api::post.post.findFeatured",
config: {
middlewares: ["api::post.log-request"],
},
}

Global middleware​

Global middleware runs on every request. It is configured in config/middlewares.ts:

// config/middlewares.ts
export default [
"strapi::logger",
"strapi::errors",
"strapi::security",
"strapi::cors",
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"strapi::session",
"strapi::favicon",
"strapi::public",
];

This is the default middleware stack. Each entry runs in order. You can:

  • Reorder entries to change execution priority
  • Remove entries you do not need
  • Add custom global middleware

Custom global middleware​

Create a global middleware:

// src/middlewares/request-timer.js
export default (config, { strapi }) => {
return async (ctx, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;
ctx.set("X-Response-Time", `${duration}ms`);
};
};

Add it to the middleware stack:

// config/middlewares.ts
export default [
"strapi::logger",
"strapi::errors",
"strapi::security",
"strapi::cors",
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"global::request-timer", // Custom middleware
"strapi::session",
"strapi::favicon",
"strapi::public",
];

Configuring built-in middleware​

You can customize built-in middleware by passing configuration:

// config/middlewares.ts
export default [
"strapi::logger",
"strapi::errors",
{
name: "strapi::security",
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
"img-src": ["'self'", "data:", "blob:", "https://your-cdn.com"],
},
},
},
},
{
name: "strapi::cors",
config: {
origin: ["http://localhost:3000", "https://yourdomain.com"],
methods: ["GET", "POST", "PUT", "DELETE"],
headers: ["Content-Type", "Authorization"],
},
},
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"strapi::session",
"strapi::favicon",
"strapi::public",
];

Practical example - rate limiting middleware​

A simple in-memory rate limiter:

// src/middlewares/rate-limit.js
const requestCounts = new Map();

export default (config, { strapi }) => {
const maxRequests = config.max || 100;
const windowMs = config.window || 60 * 1000;

return async (ctx, next) => {
const ip = ctx.ip;
const now = Date.now();
const key = `${ip}`;

if (!requestCounts.has(key)) {
requestCounts.set(key, { count: 1, resetAt: now + windowMs });
} else {
const record = requestCounts.get(key);
if (now > record.resetAt) {
record.count = 1;
record.resetAt = now + windowMs;
} else {
record.count++;
}

if (record.count > maxRequests) {
ctx.status = 429;
ctx.body = {
error: {
status: 429,
name: "TooManyRequestsError",
message: "Rate limit exceeded. Try again later.",
},
};
return;
}
}

await next();
};
};

Add to the middleware stack:

// config/middlewares.ts
export default [
"strapi::logger",
"strapi::errors",
{
name: "global::rate-limit",
config: { max: 100, window: 60000 },
},
"strapi::security",
// ... rest of middleware
];

Testing your custom routes​

After creating custom routes, restart the server and test:

# List all registered routes
npm run strapi routes:list

# Test the featured posts endpoint
curl http://localhost:1337/api/posts/featured

# Test the find-by-slug endpoint
curl http://localhost:1337/api/posts/by-slug/getting-started-with-strapi

# Test the related posts endpoint
curl http://localhost:1337/api/posts/abc123def456/related

Tip: Run npm run strapi routes:list whenever you change routes. It shows all registered routes, their methods, and handlers - useful for debugging route conflicts.

For more advanced patterns, see the Custom Routes and Endpoints and Middleware and Policies references.

Summary​

You learned:

  • Strapi's request lifecycle: middleware --> policies --> controller
  • How to configure core routes with policies and middleware
  • How to create custom routes for new endpoints
  • Why route order matters and how to control it
  • How to write policies for access control (is-owner, is-authenticated)
  • How to write route middleware and global middleware
  • How to configure built-in middleware (CORS, security, etc.)
  • A practical rate limiting example

Your blog API now has custom endpoints for featured posts and slug-based lookups, access control to ensure users can only edit their own posts, and request logging middleware. The next chapter covers event-driven patterns.

Next up: Lifecycle Hooks & Webhooks - reacting to content events with Document Service middleware and notifying external services with webhooks.