Introduction
A Headless CMS separates content from presentation, giving developers complete flexibility. Strapi 5, with native TypeScript support, makes it easy to build robust and secure APIs. This tutorial walks you through an advanced setup that includes complex content types, custom middlewares, and an optimized deployment process. You will learn how to avoid common pitfalls while following production-grade standards.
Prerequisites
- Node.js 20+ and npm/yarn
- Solid knowledge of TypeScript and REST/GraphQL
- An account with a hosting provider (Vercel, Railway or VPS)
- Strapi 5 CLI installed globally
Initialize the Strapi Project
npx create-strapi-app@latest my-cms --quickstart --ts
cd my-cms
npm run developThis command creates a Strapi 5 project with TypeScript enabled by default. The --quickstart flag starts a SQLite database for rapid development.
Create the Article Content Type
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"content": {
"type": "richtext"
},
"slug": {
"type": "uid",
"targetField": "title"
}
}
}The schema.json file defines the Article model. The uid field automatically generates unique slugs from the title.
Configure API Permissions
export default {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
},
response: {
status: 200,
headers: {
'Cache-Control': 'public, max-age=300',
},
},
};This file adjusts default limits and adds cache headers. It improves performance for public requests while remaining secure.
Add a Custom Controller
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article', ({ strapi }) => ({
async findBySlug(ctx) {
const { slug } = ctx.params;
const article = await strapi.db.query('api::article.article').findOne({
where: { slug },
populate: ['author'],
});
if (!article) return ctx.notFound();
return article;
},
}));This controller adds a custom route /articles/slug/:slug. It uses the query builder for optimal performance with relation population.
Next.js Integration with Typed Fetch
export async function getArticle(slug: string) {
const res = await fetch(`${process.env.STRAPI_URL}/api/articles/slug/${slug}`, {
headers: { Authorization: `Bearer ${process.env.STRAPI_TOKEN}` },
next: { revalidate: 300 },
});
return res.json();
}
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const data = await getArticle(params.slug);
return <h1>{data.title}</h1>;
}This Next.js code fetches data from Strapi with ISR revalidation. Use environment variables for tokens to keep access secure.
Best Practices
- Always enable draftAndPublish for critical content
- Use middlewares for validation and sanitization
- Configure strict request limits in production
- Version your content types with migrations
- Enable structured logging for monitoring
Common Mistakes to Avoid
- Forgetting to regenerate TypeScript types after schema changes
- Leaving public permissions too permissive
- Ignoring CORS configuration for frontend origins
- Not setting up automatic database backups
Further Reading
Deepen your skills with our advanced Headless CMS courses.