Introduction
In 2026, Headless CMS platforms dominate modern architectures thanks to their complete decoupling: an API-first backend (REST/GraphQL) serving any frontend (web, mobile, IoT). Unlike monolithic CMS like WordPress, Strapi—open-source and ultra-flexible—offers granular control via TypeScript, plugins, and custom hooks.
This expert tutorial guides you step-by-step to deploy Strapi v5 with Postgres, create scalable content-types (e.g., articles with relations), enable GraphQL and users-permissions, then integrate everything into a Next.js 15 App Router app with optimized queries, SSR, and JWT authentication. Imagine a multilingual blog where webhooks instantly sync drafts via realtime updates.
Why it matters: Achieve 99.99% uptime, explosive SEO via SSG/ISR, and 70% lower AWS costs. Result: A production-ready CMS in 2 hours, bookmarked by every lead dev. (128 words)
Prerequisites
- Node.js 20+ and Yarn 4 (pnpm recommended for lockfiles)
- PostgreSQL 16 local or Docker (port 5432)
- Next.js 15+ with TypeScript and App Router
- Advanced skills: TypeScript, GraphQL, Prisma-like schemas, JWT/OAuth
- Tools: Git, Docker Compose, Vercel/Netlify for deployment
1. Initialize the Strapi Project
npx create-strapi-app@latest cms-headless --quickstart --no-run
cd cms-headless
yarn add @strapi/provider-upload-cloudinary @strapi/plugin-graphql @strapi/plugin-users-permissions
yarn strapi install graphql users-permissionsThis command creates a Strapi v5 project with SQLite by default (switch to Postgres later). It adds GraphQL plugins for flexible queries and users-permissions for RBAC auth. Avoid --quickstart in production; use --template for custom seeds. Run yarn develop for the admin UI on localhost:1337.
2. Set Up PostgreSQL via Docker
For a scalable database, replace SQLite with Postgres. Create a docker-compose file for isolation. This avoids local conflicts and simulates production (AWS RDS). Expose port 5432 and seed with an initial admin user.
docker-compose.yml for DB
version: '3'
services:
postgres:
image: postgres:16
restart: always
environment:
POSTGRES_DB: strapi
POSTGRES_USER: strapi
POSTGRES_PASSWORD: strapi2026
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:This docker-compose launches persistent Postgres with volumes. Use psql to verify: docker exec -it . Pitfall: Forget Strapi migrations after DB config, and schemas are lost. Run docker-compose up -d && sleep 10.
config/database.ts
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', '127.0.0.1'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi2026'),
ssl: env.bool('DATABASE_SSL', false),
},
debug: false,
acquireConnectionTimeout: 60000,
},
});Configures Strapi for Postgres with env var fallbacks. Copy to .env: DATABASE_HOST=localhost etc. yarn build && yarn develop triggers auto-migrations. Caution: Set ssl=true in production (RDS), or connections will be rejected.
3. Create a Scalable Article Content-Type
Access the admin UI (http://localhost:1337/admin) > Content-Type Builder > + Article. For experts, define via TS files: one-to-many relations (author), reusable components (SEO), locales (i18n). This enables Git versioning and CI/CD.
schema.ts for Article
export default {
kind: 'collectionType',
collectionName: 'articles',
info: {
singularName: 'article',
pluralName: 'articles',
displayName: 'Article',
description: '',
},
options: {
draftAndPublish: true,
},
pluginOptions: {
i18n: {
localizedFields: ['title', 'content'],
},
},
attributes: {
title: {
type: 'string',
required: true,
},
slug: {
type: 'uid',
targetField: 'title',
},
content: {
type: 'richtext',
},
author: {
type: 'relation',
relation: 'manyToOne',
target: 'api::user.user',
},
publishedAt: {
type: 'datetime',
},
},
};Defines an Article with draft/publish, i18n, auto-slug, rich-text, and User relation (via plugin). yarn strapi dev auto-reloads. Pitfall: Without draftAndPublish, no staging; test with seeders to populate.
config/plugins.ts for Auth & GraphQL
export default {
'users-permissions': {
config: {
jwt: {
expiresIn: '7d',
},
register: {
validatePasswordStrength: true,
},
},
},
graphql: {
config: {
endpoint: '/graphql',
shadowCRUD: true,
playgroundAlways: false,
depthLimit: 10,
amountLimit: 100,
apolloServer: {
tracing: false,
},
},
},
};Enables 7-day JWT, password strength validation, and GraphQL with depth/amount limits to prevent DoS. Create superadmin via /admin. Avoid playgroundAlways=true in production (exposes schemas). Test: curl POST /graphql with introspection.
4. Integrate Strapi with Next.js App Router
Create a Next.js frontend to consume the API. Use native fetch + cache: 'no-store' for realtime, or SWR. For experts: typed TS client, auth middleware, ISR for static blogs.
Install Next.js + Dependencies
npx create-next-app@15 frontend-headless --ts --app --tailwind --eslint --no-src-dir
cd frontend-headless
yarn add graphql-request @apollo/client @tanstack/react-query swr
yarn add -D @types/nodeInitializes Next.js 15 App Router with TS/Tailwind. Adds GraphQL clients: lightweight graphql-request, full-featured Apollo. react-query/SWR for offline-first caching. yarn dev on port 3000.
lib/strapi-client.ts
import { GraphQLClient } from 'graphql-request';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
export const strapiClient = new GraphQLClient(`${STRAPI_URL}/graphql`);
export async function getArticles({ token }: { token?: string }) {
strapiClient.setHeader('Authorization', token ? `Bearer ${token}` : '');
const query = /* GraphQL */ `
query Articles {
articles(pagination: { limit: 10 }, publicationState: LIVE, sort: "publishedAt:desc") {
data {
id
attributes {
title
slug
content
publishedAt
author {
data {
attributes { name }
}
}
}
}
}
}
`;
const { articles } = await strapiClient.request(query);
return articles;
}
export async function login(email: string, password: string) {
const mutation = /* GraphQL */ `
mutation Login($input: UsersPermissionsLoginInput!) {
login(input: $input) {
jwt
user {
id
username
}
}
}
`;
const data = await strapiClient.request(mutation, { input: { identifier: email, password } });
return data.login;
}Typed GraphQL client with Bearer auth. Paginated/live-only query, sorted. Login mutation for JWT. Use NEXT_PUBLIC_STRAPI_URL env for production. Pitfall: Forget publicationState=LIVE, and drafts become public.
app/blog/page.tsx
import { getArticles } from '@/lib/strapi-client';
export default async function BlogPage() {
const { articles } = await getArticles({});
return (
<div className="container mx-auto p-8">
<h1 className="text-4xl font-bold mb-8">Articles</h1>
<div className="grid md:grid-cols-2 gap-6">
{articles.data.map((article: any) => (
<article key={article.id} className="border p-6 rounded-lg">
<h2 className="text-2xl font-semibold mb-2">{article.attributes.title}</h2>
<p className="text-gray-600 mb-4">Par {article.attributes.author?.data?.attributes?.name}</p>
<div className="prose max-w-none" dangerouslySetInnerHTML={{ __html: article.attributes.content.substring(0, 200) + '...' }} />
<time>{new Date(article.attributes.publishedAt).toLocaleDateString()}</time>
</article>
))}
</div>
</div>
);
}
export const revalidate = 3600; // ISR 1hSSR/ISR page fetching live articles. Tailwind styling, safe rich-text preview. revalidate for hybrid static/dynamic. For auth: Pass token via cookies/middleware. Test: Publish an Article in admin.
Best Practices
- Rate limiting & CORS: Add nginx or Strapi middleware (config/middlewares.ts) for 100 req/min/IP.
- Git Migrations: Version schemas.ts, use strapi export for DB snapshots.
- Realtime: Webhooks on entry.update → Pusher/Supabase for live previews.
- SEO/Perf: ISR + Next Image for articles, Cloudinary CDNs for media.
- Security: Granular RBAC (custom roles), OWASP ZAP scans, Vault secrets.
Common Errors to Avoid
- Unmigrated DB: yarn build without DB config = 500 crash; always run
strapi db:migrate. - GraphQL Depth Explosion: Without limits, N+1 DoS queries; set depthLimit=7.
- JWT Leaks: Store in httpOnly cookies, not localStorage (XSS vuln).
- CORS Wildcards: '*' exposes to CSRF; whitelist prod/staging frontend domains.
Next Steps
Dive deeper with Strapi v5 migrations, GraphQL Federation for multi-CMS. Deploy on Railway/Render (1-click Postgres).
Check out our expert Learni Next.js & Headless training for live coding masterclasses and performance audits.