Build a Full-Stack App with Strapi 5 and Next.js 15 App Router

Strapi 5 is the world's leading open-source headless CMS, powering over 60,000 projects from startups to enterprises. Paired with Next.js 15 App Router, you get a production-ready content platform with full TypeScript support, a beautiful admin interface, and blazing-fast server-rendered pages — all without vendor lock-in.
What You Will Learn
By the end of this tutorial, you will be able to:
- Set up a Strapi 5 project with custom content types and relations
- Configure REST API permissions for public and authenticated access
- Build a Next.js 15 App Router frontend that fetches Strapi content
- Create a typed API client to safely consume Strapi's REST endpoints
- Implement article listings, category filtering, and detail pages
- Use Strapi Draft/Publish with Next.js Draft Mode for live content preview
- Enable internationalization (i18n) with locale-aware fetching
- Optimize media images with the Next.js Image component
- Deploy Strapi on Railway and Next.js on Vercel for production
Prerequisites
Before getting started, make sure you have:
- Node.js 20+ (check with
node -v) - npm or yarn as your package manager
- Solid knowledge of TypeScript and React
- Familiarity with Next.js App Router concepts (layouts, Server Components,
fetch) - A code editor — VS Code with the Strapi extension is recommended
- Basic understanding of REST APIs and HTTP
Why Strapi 5?
The headless CMS landscape in 2026 is rich with options. Here is how Strapi 5 compares against the major alternatives:
| Feature | Strapi 5 | Payload CMS 3 | Directus | Sanity | WordPress (headless) |
|---|---|---|---|---|---|
| Architecture | Standalone server | Inside Next.js | Standalone server | Hosted SaaS | Separate server |
| Admin UI | Custom React app | Next.js routes | Vue-based | Studio (React) | Classic dashboard |
| Database | Postgres/MySQL/SQLite | Postgres/MongoDB | Postgres/MySQL | Hosted | MySQL |
| TypeScript | Full (v5) | Full | Partial | Full | Partial |
| Self-hosted | Yes | Yes | Yes | No (paid) | Yes |
| REST API | Auto-generated | Auto-generated | Auto-generated | No (GROQ) | Via plugins |
| GraphQL | Plugin | Built-in | Built-in | Native | Via WPGraphQL |
| Open-source | Yes (MIT) | Yes (MIT) | Yes (BSL) | Partial | Yes (GPL) |
| Content modeling | GUI + API | Code-first | GUI | Schema + GROQ | PHP + GUI |
| i18n | Built-in plugin | Built-in | Built-in | Built-in | Via plugins |
| Draft/Publish | Built-in | Built-in | Yes | Yes | Built-in |
Strapi 5's key advantages:
- No code required for content modeling — the admin GUI handles everything
- Auto-generated REST + GraphQL APIs — zero boilerplate API code
- Plugin ecosystem with i18n, email, upload, and more out of the box
- Fully self-hosted with no SaaS lock-in
- Strapi v5 Document Service replaces the old Entity Service for cleaner, locale-aware queries
What You Will Build
You will build StratoBlog — a multilingual developer blog platform with:
- Articles with rich text, cover images, reading time, and SEO metadata
- Categories for organizing content (e.g. "React", "DevOps", "AI")
- Authors with bios and profile photos, linked to articles
- Category filtering on the frontend
- Article detail pages with Strapi Blocks rich text renderer
- Draft Mode preview so editors can preview unpublished content
- English and French locales using Strapi's i18n plugin
Step 1: Create the Strapi 5 Project
Strapi provides an official CLI that scaffolds a complete project in under two minutes.
npx create-strapi@latest stratoblog-cmsThe CLI will prompt you for several options:
? What is the name of your project? stratoblog-cms
? Choose your preferred language: TypeScript
? Choose your default database client: sqlite (for dev) / postgres (for prod)
? Start with an example structure & data? No
For local development, SQLite is the simplest option — no Docker required. You can switch to PostgreSQL for production using environment variables.
Once the scaffold finishes, start the development server:
cd stratoblog-cms
npm run developStrapi will open at http://localhost:1337/admin. On the first run, you will be prompted to create an admin account. Fill in your name, email, and password, then click Let's start.
Strapi 5 Project Structure
stratoblog-cms/
├── config/
│ ├── database.ts # Database connection config
│ ├── middlewares.ts # Middleware stack
│ ├── plugins.ts # Plugin configuration
│ └── server.ts # Server settings (host, port)
├── src/
│ ├── admin/ # Admin UI customizations
│ ├── api/ # Auto-generated content type APIs
│ │ └── article/
│ │ ├── content-types/article/schema.json
│ │ ├── controllers/article.ts
│ │ ├── routes/article.ts
│ │ └── services/article.ts
│ └── extensions/ # Plugin extensions
├── public/
│ └── uploads/ # Uploaded media files
├── .env # Environment variables
└── package.json
Tip: Strapi 5 auto-generates the src/api/ folder when you create a content type through the admin UI. You rarely need to edit these files manually — but you can extend them freely.
Step 2: Define Content Types
Content types in Strapi are defined visually in the Content-Type Builder. Navigate to the sidebar and click Content-Type Builder.
Create the Category Collection
Click Create new collection type and name it Category. Add these fields:
| Field | Type | Options |
|---|---|---|
name | Text (Short) | Required, Unique |
slug | UID | Attached to name, Required |
description | Text (Long) | Optional |
color | Text (Short) | e.g. #3B82F6 |
Click Save and wait for Strapi to restart.
Create the Author Collection
Create another collection type named Author:
| Field | Type | Options |
|---|---|---|
name | Text (Short) | Required |
bio | Text (Long) | Optional |
avatar | Media (Single image) | Optional |
twitter | Text (Short) | Optional |
github | Text (Short) | Optional |
Create the Article Collection
Now create the main Article collection type with these fields:
| Field | Type | Options |
|---|---|---|
title | Text (Short) | Required |
slug | UID | Attached to title, Required |
summary | Text (Long) | Required |
content | Rich Text (Blocks) | Required |
coverImage | Media (Single image) | Optional |
readingTime | Number (Integer) | Optional |
publishedDate | Date | Optional |
featured | Boolean | Default false |
seoTitle | Text (Short) | Optional |
seoDescription | Text (Long) | Optional |
After creating these fields, add relations:
- Click Add another field and choose Relation
- Article has many Categories: choose
Article belongs to many Category - Add another relation: Article belongs to one Author
Your final Article content type has:
- All the basic fields above
- A
categoriesrelation (many-to-many with Category) - An
authorrelation (many-to-one with Author)
Click Save and let Strapi restart.
Verify the Generated Schema
After saving, Strapi generates src/api/article/content-types/article/schema.json. You can inspect it:
{
"kind": "collectionType",
"collectionName": "articles",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "title", "required": true },
"summary": { "type": "text", "required": true },
"content": { "type": "blocks" },
"coverImage": { "type": "media", "multiple": false },
"readingTime": { "type": "integer" },
"featured": { "type": "boolean", "default": false },
"categories": {
"type": "relation",
"relation": "manyToMany",
"target": "api::category.category",
"inversedBy": "articles"
},
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "api::author.author",
"inversedBy": "articles"
}
}
}Strapi 5 introduced "Blocks" rich text — a structured JSON format that replaces the old Markdown/HTML fields. Each block has a type (paragraph, heading, image, code, list) and children. The @strapi/blocks-react-renderer package renders these blocks on the frontend.
Add Sample Content
In the Strapi admin, navigate to Content Manager and create a few entries:
- Create 2-3 Categories (e.g. "React", "DevOps", "TypeScript")
- Create 1-2 Authors with names and bios
- Create 3-5 Articles, assigning categories and an author to each, and click Publish
Step 3: Configure API Permissions
By default, Strapi locks all API endpoints. You must explicitly grant public access.
Go to Settings → Users & Permissions Plugin → Roles → Public.
Enable the following permissions:
For Article:
find(GET /api/articles)findOne(GET /api/articles/:id)
For Category:
find(GET /api/categories)findOne(GET /api/categories/:id)
For Author:
find(GET /api/authors)findOne(GET /api/authors/:id)
For Upload (media files):
findfindOne
Click Save in the top right corner.
Test your API in the browser:
http://localhost:1337/api/articles?populate=*
You should see a JSON response with your published articles, including populated relations.
Security note: Only enable the minimum required permissions for the Public role. Never enable create, update, or delete for unauthenticated users unless you have intentional reasons and additional safeguards.
Understanding Strapi 5 Query Parameters
Strapi's REST API supports powerful query parameters:
# Populate all relations one level deep
GET /api/articles?populate=*
# Populate specific fields
GET /api/articles?populate[author][fields][0]=name&populate[author][fields][1]=bio
# Filter by field
GET /api/articles?filters[featured][$eq]=true
# Filter by relation
GET /api/articles?filters[categories][slug][$eq]=react
# Sort results
GET /api/articles?sort[0]=publishedDate:desc
# Paginate
GET /api/articles?pagination[page]=1&pagination[pageSize]=10
# Select specific fields
GET /api/articles?fields[0]=title&fields[1]=slug&fields[2]=summary
Step 4: Create the Next.js 15 Project
Open a new terminal (keep Strapi running) and scaffold the Next.js frontend:
npx create-next-app@latest stratoblog --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd stratoblogInstall the packages you need:
npm install @strapi/blocks-react-renderer qs
npm install -D @types/qs@strapi/blocks-react-renderer— renders Strapi v5 Blocks rich text on the frontendqs— serializes complex query parameter objects for Strapi API calls
Configure environment variables by creating .env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token_here
NEXT_PUBLIC_DRAFT_SECRET=my_draft_secret_2026To generate an API token: go to Strapi admin → Settings → API Tokens → Create new API token. Give it Read-only access and copy the generated token.
Update next.config.ts to allow Strapi's media domain:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "1337",
pathname: "/uploads/**",
},
{
// For production Strapi on Railway or Render
protocol: "https",
hostname: "*.up.railway.app",
pathname: "/uploads/**",
},
],
},
};
export default nextConfig;Step 5: Build the Strapi API Client
Create a typed API client that wraps Strapi's REST endpoints. This gives you autocomplete, type safety, and a single place to update if the API changes.
Create src/lib/strapi.ts:
import qs from "qs";
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL ?? "http://localhost:1337";
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN ?? "";
// --- Type definitions ---
export interface StrapiImage {
id: number;
url: string;
alternativeText: string | null;
width: number;
height: number;
formats?: {
thumbnail?: { url: string; width: number; height: number };
small?: { url: string; width: number; height: number };
medium?: { url: string; width: number; height: number };
};
}
export interface Category {
id: number;
documentId: string;
name: string;
slug: string;
description: string | null;
color: string | null;
}
export interface Author {
id: number;
documentId: string;
name: string;
bio: string | null;
twitter: string | null;
github: string | null;
avatar: StrapiImage | null;
}
export interface Article {
id: number;
documentId: string;
title: string;
slug: string;
summary: string;
content: StrapiBlock[];
coverImage: StrapiImage | null;
readingTime: number | null;
featured: boolean;
publishedDate: string | null;
publishedAt: string;
seoTitle: string | null;
seoDescription: string | null;
categories: Category[];
author: Author | null;
}
// Strapi Blocks types (simplified)
export interface StrapiBlock {
type: string;
children: Array<{ type: string; text?: string; url?: string; children?: StrapiBlock["children"] }>;
level?: number;
format?: string;
image?: StrapiImage;
}
export interface StrapiPagination {
page: number;
pageSize: number;
pageCount: number;
total: number;
}
export interface StrapiListResponse<T> {
data: T[];
meta: { pagination: StrapiPagination };
}
export interface StrapiSingleResponse<T> {
data: T;
meta: Record<string, unknown>;
}
// --- Fetch helper ---
async function strapiRequest<T>(
path: string,
queryParams?: Record<string, unknown>,
options?: RequestInit
): Promise<T> {
const query = queryParams ? `?${qs.stringify(queryParams, { encodeValuesOnly: true })}` : "";
const url = `${STRAPI_URL}/api${path}${query}`;
const res = await fetch(url, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${STRAPI_API_TOKEN}`,
},
...options,
});
if (!res.ok) {
throw new Error(`Strapi API error: ${res.status} ${res.statusText} — ${url}`);
}
return res.json() as Promise<T>;
}
// --- Helper: resolve media URL ---
export function getStrapiMediaUrl(image: StrapiImage | null | undefined): string | null {
if (!image) return null;
const url = image.url;
// Strapi v5 returns absolute URLs when configured, relative otherwise
if (url.startsWith("http")) return url;
return `${STRAPI_URL}${url}`;
}
// --- API functions ---
export async function getArticles(options?: {
page?: number;
pageSize?: number;
categorySlug?: string;
featured?: boolean;
preview?: boolean;
locale?: string;
}): Promise<StrapiListResponse<Article>> {
const { page = 1, pageSize = 10, categorySlug, featured, preview, locale } = options ?? {};
return strapiRequest<StrapiListResponse<Article>>(
"/articles",
{
populate: {
coverImage: { fields: ["url", "alternativeText", "width", "height", "formats"] },
categories: { fields: ["name", "slug", "color"] },
author: { fields: ["name", "bio"], populate: { avatar: { fields: ["url", "alternativeText"] } } },
},
filters: {
...(categorySlug ? { categories: { slug: { $eq: categorySlug } } } : {}),
...(featured !== undefined ? { featured: { $eq: featured } } : {}),
},
sort: ["publishedDate:desc"],
pagination: { page, pageSize },
...(locale ? { locale } : {}),
...(preview ? { status: "draft" } : {}),
},
{
// Cache for 60 seconds in production; always fresh in preview
next: preview ? { revalidate: 0 } : { revalidate: 60 },
}
);
}
export async function getArticleBySlug(
slug: string,
preview = false,
locale?: string
): Promise<Article | null> {
const res = await strapiRequest<StrapiListResponse<Article>>(
"/articles",
{
filters: { slug: { $eq: slug } },
populate: {
coverImage: { fields: ["url", "alternativeText", "width", "height", "formats"] },
categories: { fields: ["name", "slug", "color"] },
author: {
fields: ["name", "bio", "twitter", "github"],
populate: { avatar: { fields: ["url", "alternativeText", "width", "height"] } },
},
},
...(locale ? { locale } : {}),
...(preview ? { status: "draft" } : {}),
},
{
next: preview ? { revalidate: 0 } : { revalidate: 60 },
}
);
return res.data[0] ?? null;
}
export async function getCategories(): Promise<Category[]> {
const res = await strapiRequest<StrapiListResponse<Category>>(
"/categories",
{ sort: ["name:asc"], pagination: { pageSize: 100 } },
{ next: { revalidate: 300 } }
);
return res.data;
}
export async function getFeaturedArticles(): Promise<Article[]> {
const res = await getArticles({ featured: true, pageSize: 3 });
return res.data;
}Strapi 5 uses documentId (a UUID string) instead of numeric id for document identification in the new Document Service API. Always use documentId when building Strapi 5 CRUD operations. Numeric id is still available but considered internal.
Step 6: Display the Articles List
Article Card Component
Create src/components/ArticleCard.tsx:
import Image from "next/image";
import Link from "next/link";
import { Article, getStrapiMediaUrl } from "@/lib/strapi";
interface ArticleCardProps {
article: Article;
}
export function ArticleCard({ article }: ArticleCardProps) {
const coverUrl = getStrapiMediaUrl(article.coverImage);
const publishedDate = article.publishedDate
? new Date(article.publishedDate).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
return (
<article className="group flex flex-col overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm transition-shadow hover:shadow-md">
{coverUrl && (
<Link href={`/articles/${article.slug}`} className="block overflow-hidden">
<div className="relative h-48 w-full">
<Image
src={coverUrl}
alt={article.coverImage?.alternativeText ?? article.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
</Link>
)}
<div className="flex flex-1 flex-col gap-3 p-5">
{/* Categories */}
{article.categories.length > 0 && (
<div className="flex flex-wrap gap-2">
{article.categories.map((cat) => (
<Link
key={cat.slug}
href={`/categories/${cat.slug}`}
className="rounded-full px-3 py-1 text-xs font-medium transition-colors"
style={{
backgroundColor: cat.color ? `${cat.color}20` : "#3B82F620",
color: cat.color ?? "#3B82F6",
}}
>
{cat.name}
</Link>
))}
</div>
)}
{/* Title */}
<Link href={`/articles/${article.slug}`}>
<h2 className="text-lg font-semibold leading-snug text-gray-900 transition-colors group-hover:text-blue-600">
{article.title}
</h2>
</Link>
{/* Summary */}
<p className="flex-1 text-sm leading-relaxed text-gray-500 line-clamp-3">
{article.summary}
</p>
{/* Footer */}
<div className="flex items-center justify-between border-t border-gray-50 pt-3">
<div className="flex items-center gap-2">
{article.author && (
<span className="text-xs text-gray-500">{article.author.name}</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-gray-400">
{article.readingTime && <span>{article.readingTime} min read</span>}
{publishedDate && <span>{publishedDate}</span>}
</div>
</div>
</div>
</article>
);
}Articles List Page
Create src/app/articles/page.tsx:
import { Suspense } from "react";
import { getArticles, getCategories } from "@/lib/strapi";
import { ArticleCard } from "@/components/ArticleCard";
import { CategoryFilter } from "@/components/CategoryFilter";
interface ArticlesPageProps {
searchParams: Promise<{ category?: string; page?: string }>;
}
export const metadata = {
title: "Articles — StratoBlog",
description: "Explore our latest articles on React, DevOps, TypeScript, and more.",
};
export default async function ArticlesPage({ searchParams }: ArticlesPageProps) {
const params = await searchParams;
const categorySlug = params.category;
const page = parseInt(params.page ?? "1", 10);
const [articlesResponse, categories] = await Promise.all([
getArticles({ page, pageSize: 9, categorySlug }),
getCategories(),
]);
const { data: articles, meta } = articlesResponse;
const { pagination } = meta;
return (
<main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<header className="mb-10 text-center">
<h1 className="text-4xl font-bold tracking-tight text-gray-900">
Articles
</h1>
<p className="mt-3 text-lg text-gray-500">
Deep-dives, tutorials, and insights for modern developers.
</p>
</header>
<Suspense fallback={<div className="h-10" />}>
<CategoryFilter categories={categories} activeSlug={categorySlug} />
</Suspense>
{articles.length === 0 ? (
<div className="mt-16 text-center text-gray-400">
No articles found{categorySlug ? ` in this category` : ""}.
</div>
) : (
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{articles.map((article) => (
<ArticleCard key={article.documentId} article={article} />
))}
</div>
)}
{/* Pagination */}
{pagination.pageCount > 1 && (
<nav className="mt-12 flex justify-center gap-2">
{Array.from({ length: pagination.pageCount }, (_, i) => i + 1).map((p) => (
<a
key={p}
href={`/articles?${categorySlug ? `category=${categorySlug}&` : ""}page=${p}`}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
p === page
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{p}
</a>
))}
</nav>
)}
</main>
);
}Step 7: Article Detail Page
Dynamic Route
Create src/app/articles/[slug]/page.tsx:
import { notFound } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { BlocksRenderer } from "@strapi/blocks-react-renderer";
import { getArticleBySlug, getArticles, getStrapiMediaUrl } from "@/lib/strapi";
import { draftMode } from "next/headers";
import type { Metadata } from "next";
interface ArticlePageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const res = await getArticles({ pageSize: 100 });
return res.data.map((article) => ({ slug: article.slug }));
}
export async function generateMetadata({ params }: ArticlePageProps): Promise<Metadata> {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) return { title: "Not Found" };
const ogImage = getStrapiMediaUrl(article.coverImage);
return {
title: article.seoTitle ?? article.title,
description: article.seoDescription ?? article.summary,
openGraph: {
title: article.seoTitle ?? article.title,
description: article.seoDescription ?? article.summary,
images: ogImage ? [{ url: ogImage }] : [],
},
};
}
export default async function ArticlePage({ params }: ArticlePageProps) {
const { slug } = await params;
const { isEnabled: isPreview } = await draftMode();
const article = await getArticleBySlug(slug, isPreview);
if (!article) {
notFound();
}
const coverUrl = getStrapiMediaUrl(article.coverImage);
const authorAvatarUrl = getStrapiMediaUrl(article.author?.avatar ?? null);
const publishedDate = article.publishedDate
? new Date(article.publishedDate).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
: null;
return (
<main className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
{isPreview && (
<div className="mb-6 rounded-lg bg-yellow-50 px-4 py-3 text-sm font-medium text-yellow-800 ring-1 ring-yellow-200">
Draft Preview Mode — this content is not published yet.{" "}
<a href="/api/disable-draft" className="underline">
Exit preview
</a>
</div>
)}
{/* Categories */}
{article.categories.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
{article.categories.map((cat) => (
<Link
key={cat.slug}
href={`/categories/${cat.slug}`}
className="rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide"
style={{ backgroundColor: `${cat.color ?? "#3B82F6"}20`, color: cat.color ?? "#3B82F6" }}
>
{cat.name}
</Link>
))}
</div>
)}
<h1 className="text-3xl font-bold leading-tight text-gray-900 sm:text-4xl">
{article.title}
</h1>
<p className="mt-4 text-lg text-gray-500">{article.summary}</p>
{/* Author + meta */}
<div className="mt-6 flex items-center gap-4">
{authorAvatarUrl && (
<Image
src={authorAvatarUrl}
alt={article.author!.name}
width={40}
height={40}
className="rounded-full object-cover"
/>
)}
<div className="text-sm text-gray-600">
{article.author && <span className="font-medium">{article.author.name}</span>}
{publishedDate && (
<span className="ml-2 text-gray-400">· {publishedDate}</span>
)}
{article.readingTime && (
<span className="ml-2 text-gray-400">· {article.readingTime} min read</span>
)}
</div>
</div>
{/* Cover image */}
{coverUrl && (
<div className="relative mt-8 h-72 w-full overflow-hidden rounded-2xl sm:h-96">
<Image
src={coverUrl}
alt={article.coverImage?.alternativeText ?? article.title}
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
)}
{/* Rich text content */}
<div className="prose prose-lg prose-gray mt-10 max-w-none">
<BlocksRenderer
content={article.content}
blocks={{
paragraph: ({ children }) => <p className="leading-relaxed">{children}</p>,
heading: ({ children, level }) => {
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
return <Tag className="font-bold tracking-tight">{children}</Tag>;
},
image: ({ image }) => {
const imgUrl = getStrapiMediaUrl(image as any);
if (!imgUrl) return null;
return (
<figure className="my-8">
<div className="relative overflow-hidden rounded-xl" style={{ aspectRatio: `${image.width}/${image.height}` }}>
<Image
src={imgUrl}
alt={image.alternativeText ?? ""}
fill
className="object-contain"
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
{image.caption && (
<figcaption className="mt-2 text-center text-sm text-gray-500">
{image.caption}
</figcaption>
)}
</figure>
);
},
code: ({ children }) => (
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-sm text-gray-100">
<code>{children}</code>
</pre>
),
quote: ({ children }) => (
<blockquote className="border-l-4 border-blue-500 pl-4 italic text-gray-600">
{children}
</blockquote>
),
}}
/>
</div>
{/* Author bio */}
{article.author?.bio && (
<aside className="mt-12 rounded-2xl bg-gray-50 p-6">
<div className="flex items-start gap-4">
{authorAvatarUrl && (
<Image
src={authorAvatarUrl}
alt={article.author.name}
width={56}
height={56}
className="rounded-full object-cover"
/>
)}
<div>
<p className="font-semibold text-gray-900">{article.author.name}</p>
<p className="mt-1 text-sm text-gray-500">{article.author.bio}</p>
<div className="mt-2 flex gap-3">
{article.author.twitter && (
<a href={`https://twitter.com/${article.author.twitter}`} className="text-xs text-blue-500 hover:underline" target="_blank" rel="noopener noreferrer">
@{article.author.twitter}
</a>
)}
{article.author.github && (
<a href={`https://github.com/${article.author.github}`} className="text-xs text-gray-500 hover:underline" target="_blank" rel="noopener noreferrer">
GitHub
</a>
)}
</div>
</div>
</div>
</aside>
)}
</main>
);
}Step 8: Category Navigation
Category Filter Component
Create src/components/CategoryFilter.tsx:
"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Category } from "@/lib/strapi";
interface CategoryFilterProps {
categories: Category[];
activeSlug?: string;
}
export function CategoryFilter({ categories, activeSlug }: CategoryFilterProps) {
const searchParams = useSearchParams();
return (
<nav className="flex flex-wrap justify-center gap-2" aria-label="Filter by category">
<Link
href="/articles"
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-colors ${
!activeSlug
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-200 bg-white text-gray-600 hover:border-blue-200 hover:text-blue-600"
}`}
>
All
</Link>
{categories.map((cat) => (
<Link
key={cat.slug}
href={`/articles?category=${cat.slug}`}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition-colors ${
cat.slug === activeSlug
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-200 bg-white text-gray-600 hover:border-blue-200 hover:text-blue-600"
}`}
style={cat.slug === activeSlug && cat.color ? { backgroundColor: cat.color, borderColor: cat.color } : {}}
>
{cat.name}
</Link>
))}
</nav>
);
}Category Landing Page
Create src/app/categories/[slug]/page.tsx:
import { getArticles, getCategories } from "@/lib/strapi";
import { ArticleCard } from "@/components/ArticleCard";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
interface CategoryPageProps {
params: Promise<{ slug: string }>;
}
export async function generateStaticParams() {
const categories = await getCategories();
return categories.map((cat) => ({ slug: cat.slug }));
}
export async function generateMetadata({ params }: CategoryPageProps): Promise<Metadata> {
const { slug } = await params;
const categories = await getCategories();
const cat = categories.find((c) => c.slug === slug);
if (!cat) return { title: "Category Not Found" };
return {
title: `${cat.name} Articles — StratoBlog`,
description: cat.description ?? `Browse all ${cat.name} articles.`,
};
}
export default async function CategoryPage({ params }: CategoryPageProps) {
const { slug } = await params;
const [articlesResponse, categories] = await Promise.all([
getArticles({ categorySlug: slug, pageSize: 12 }),
getCategories(),
]);
const category = categories.find((c) => c.slug === slug);
if (!category) notFound();
const { data: articles } = articlesResponse;
return (
<main className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<header className="mb-10">
<div
className="mb-3 inline-block rounded-full px-4 py-1 text-sm font-semibold"
style={{ backgroundColor: `${category.color ?? "#3B82F6"}20`, color: category.color ?? "#3B82F6" }}
>
Category
</div>
<h1 className="text-4xl font-bold text-gray-900">{category.name}</h1>
{category.description && (
<p className="mt-3 text-lg text-gray-500">{category.description}</p>
)}
</header>
{articles.length === 0 ? (
<p className="text-center text-gray-400">No articles in this category yet.</p>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{articles.map((article) => (
<ArticleCard key={article.documentId} article={article} />
))}
</div>
)}
</main>
);
}Step 9: Draft Mode Preview
Strapi's Draft/Publish system lets editors save content without publishing it. Combined with Next.js Draft Mode, you can preview unpublished articles on the frontend.
Enable Draft Mode API Routes
Create src/app/api/enable-draft/route.ts:
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret");
const slug = request.nextUrl.searchParams.get("slug");
if (secret !== process.env.NEXT_PUBLIC_DRAFT_SECRET) {
return new Response("Invalid secret", { status: 401 });
}
if (!slug) {
return new Response("Missing slug", { status: 400 });
}
const dm = await draftMode();
dm.enable();
redirect(`/articles/${slug}`);
}Create src/app/api/disable-draft/route.ts:
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET() {
const dm = await draftMode();
dm.disable();
redirect("/");
}Configure the Preview URL in Strapi
In the Strapi admin, go to Settings → Preview (available in Strapi 5 Enterprise) or set up a custom preview button.
For the community edition, configure a preview button in the Article content type by adding a lifecycle hook in src/api/article/content-types/article/lifecycles.ts:
export default {
async afterCreate(event) {
// Optionally trigger a webhook or notification
},
};The preview URL pattern for your articles is:
http://localhost:3000/api/enable-draft?secret=my_draft_secret_2026&slug=ARTICLE_SLUG
Your frontend already uses draftMode() in the article detail page (Step 7) to pass preview: true to getArticleBySlug, which adds status: "draft" to the Strapi query — showing unpublished content.
Step 10: Internationalization (i18n)
Enable the i18n Plugin in Strapi
Go to Strapi admin → Settings → Internationalization → Add a locale.
Add French (fr) as a second locale (keeping English as default).
Then go to Content-Type Builder → Article and click Add another field → i18n. This enables translations on the Article collection.
Localized API Fetching
Update src/lib/strapi.ts to pass locale parameters (already included in the code above via the locale option). Usage:
// Fetch English articles (default)
const enArticles = await getArticles({ locale: "en" });
// Fetch French articles
const frArticles = await getArticles({ locale: "fr" });
// Fetch a specific article in French
const frArticle = await getArticleBySlug("my-article-slug", false, "fr");Locale-Aware Pages
Create src/app/[locale]/articles/page.tsx for a locale-prefixed routing approach:
import { getArticles } from "@/lib/strapi";
type Locale = "en" | "fr";
const SUPPORTED_LOCALES: Locale[] = ["en", "fr"];
interface LocaleArticlesPageProps {
params: Promise<{ locale: Locale }>;
}
export async function generateStaticParams() {
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}
export default async function LocaleArticlesPage({ params }: LocaleArticlesPageProps) {
const { locale } = await params;
const { data: articles } = await getArticles({ locale });
return (
<main>
<h1>{locale === "fr" ? "Articles" : "Articles"}</h1>
{/* render articles */}
</main>
);
}Strapi i18n tip: When you create an article in English and want a French version, open the article in Strapi admin and use the locale switcher in the top-right corner. Strapi links the translations via a shared documentId.
Step 11: Image Optimization
Best Practices with Next.js Image and Strapi
Strapi stores uploaded images in public/uploads/ and automatically generates responsive format variants (thumbnail, small, medium, large) via the Upload plugin.
Always prefer the smallest adequate format for performance:
// In your API client, pick the right format
export function getStrapiImageSrc(
image: StrapiImage,
preferredFormat: "thumbnail" | "small" | "medium" | "original" = "medium"
): string {
const format = image.formats?.[preferredFormat];
if (format) {
const url = format.url.startsWith("http") ? format.url : `${STRAPI_URL}${format.url}`;
return url;
}
// Fallback to original
return image.url.startsWith("http") ? image.url : `${STRAPI_URL}${image.url}`;
}For article cards (smaller display), use the small format. For hero/cover images at full width, use medium or original.
// In ArticleCard — use small format
const coverUrl = getStrapiImageSrc(article.coverImage, "small");
// In ArticlePage hero — use medium
const coverUrl = getStrapiImageSrc(article.coverImage, "medium");Configure a Custom Loader (Optional)
If Strapi is behind a CDN in production, create src/lib/strapi-image-loader.ts:
import { ImageLoader } from "next/image";
const strapiImageLoader: ImageLoader = ({ src, width, quality }) => {
// If already absolute URL (CDN), return as-is with width param
if (src.startsWith("http")) {
return `${src}?w=${width}&q=${quality ?? 75}`;
}
// Relative URL — prepend Strapi base
return `${process.env.NEXT_PUBLIC_STRAPI_URL}${src}?w=${width}&q=${quality ?? 75}`;
};
export default strapiImageLoader;Step 12: Production Deployment
Deploy Strapi on Railway
Railway is the simplest way to deploy Strapi with a managed PostgreSQL database.
- Push your
stratoblog-cmsfolder to a GitHub repository - Go to railway.app and click New Project → Deploy from GitHub repo
- Add a PostgreSQL service to the project
- Set the following environment variables in Railway for the Strapi service:
NODE_ENV=production
DATABASE_CLIENT=postgres
DATABASE_URL=${{Postgres.DATABASE_URL}}
APP_KEYS=your_app_keys_here
API_TOKEN_SALT=your_salt_here
ADMIN_JWT_SECRET=your_admin_jwt_secret
JWT_SECRET=your_jwt_secret
TRANSFER_TOKEN_SALT=your_transfer_token_salt
# For S3 media uploads in production (recommended)
AWS_ACCESS_KEY_ID=your_key
AWS_ACCESS_SECRET=your_secret
AWS_REGION=us-east-1
AWS_BUCKET=your-bucket-nameGenerate secrets with:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"- Update
config/database.tsto support both SQLite (dev) and PostgreSQL (prod):
import path from "path";
export default ({ env }) => {
const client = env("DATABASE_CLIENT", "sqlite");
const connections = {
postgres: {
connection: {
connectionString: env("DATABASE_URL"),
ssl: env.bool("DATABASE_SSL", false) ? { rejectUnauthorized: false } : false,
},
pool: { min: 2, max: 10 },
},
sqlite: {
connection: {
filename: path.join(__dirname, "..", "..", env("DATABASE_FILENAME", ".tmp/data.db")),
},
useNullAsDefault: true,
},
};
return {
connection: {
client,
...connections[client],
acquireConnectionTimeout: env.int("DATABASE_CONNECTION_TIMEOUT", 60000),
},
};
};- Add a start command in
package.json:
{
"scripts": {
"build": "strapi build",
"start": "strapi start",
"develop": "strapi develop"
}
}Railway will auto-detect the Node.js app, run npm run build then npm run start.
Configure S3 for Media in Production
Install the AWS S3 provider:
npm install @strapi/provider-upload-aws-s3Update config/plugins.ts:
export default ({ env }) => ({
upload: {
config: {
provider: env("NODE_ENV") === "production" ? "aws-s3" : "local",
providerOptions:
env("NODE_ENV") === "production"
? {
s3Options: {
credentials: {
accessKeyId: env("AWS_ACCESS_KEY_ID"),
secretAccessKey: env("AWS_ACCESS_SECRET"),
},
region: env("AWS_REGION"),
params: { Bucket: env("AWS_BUCKET") },
},
}
: {},
actionOptions: {
upload: {},
uploadStream: {},
delete: {},
},
},
},
});Configure CORS for the Next.js Frontend
Update config/middlewares.ts:
export default [
"strapi::logger",
"strapi::errors",
{
name: "strapi::security",
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
"connect-src": ["'self'", "https:"],
"img-src": ["'self'", "data:", "blob:", "https://your-s3-bucket.s3.amazonaws.com"],
upgradeInsecureRequests: null,
},
},
},
},
{
name: "strapi::cors",
config: {
origin: [
"http://localhost:3000",
"https://your-stratoblog.vercel.app",
],
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
headers: ["Content-Type", "Authorization", "Origin", "Accept"],
keepHeaderOnError: true,
},
},
"strapi::poweredBy",
"strapi::query",
"strapi::body",
"strapi::session",
"strapi::favicon",
"strapi::public",
];Deploy Next.js on Vercel
- Push
stratoblog(Next.js) to GitHub - Go to vercel.com and import the repository
- Set environment variables:
NEXT_PUBLIC_STRAPI_URL=https://your-strapi.up.railway.app
STRAPI_API_TOKEN=your_production_api_token
NEXT_PUBLIC_DRAFT_SECRET=my_draft_secret_2026- Deploy — Vercel auto-detects Next.js and configures the build
Troubleshooting
CORS Errors in the Browser
Symptom: Access-Control-Allow-Origin error when the Next.js frontend calls Strapi.
Fix: Add your frontend URL to the origin array in config/middlewares.ts (as shown above). Make sure there are no trailing slashes in the URL.
Server Components calling Strapi from the server do not hit CORS — CORS only applies to browser-initiated requests. If you are using client-side fetching, ensure the Strapi URL is whitelisted.
Media URLs Return 404 in Production
Symptom: Images from Strapi return 404 after deploying.
Causes and fixes:
- Local uploads not persisted on Railway — Railway's filesystem is ephemeral. You must configure S3 (or another object storage) for production media, as shown in the deployment step above.
- Missing
remotePatternsinnext.config.ts— add the S3 bucket or CDN hostname toremotePatterns. - Relative URLs — ensure
getStrapiMediaUrlprepends the full Strapi URL for relative paths.
API Returns Empty Data — Permissions Not Set
Symptom: GET /api/articles returns { data: [], meta: { pagination: ... } } even though you created articles.
Fixes:
- In Strapi admin, go to Settings → Roles → Public and ensure
findis enabled for the Article content type. - Make sure articles are Published (not Draft). In Draft mode, only authenticated requests with
status: "draft"can see them. - Check that your API token has the correct scope if you are using token-based auth.
populate=* Returns Null Relations
Symptom: Relations like author or categories are null even after populating.
Fix: populate=* only goes one level deep. For nested relations (e.g. author's avatar), use explicit populate syntax:
GET /api/articles?populate[author][populate][avatar]=true&populate[categories]=true
Or use the qs library as shown in the API client above.
BlocksRenderer Shows Nothing
Symptom: Rich text renders as empty on the page.
Fix: Ensure content is populated in your query. By default, Strapi does not include the content field in list responses to reduce payload size. Add it explicitly:
fields: ["title", "slug", "summary", "content", "readingTime", "publishedDate"],Or use populate=* and verify content appears in the response JSON.
Next Steps
Now that you have a fully working StratoBlog, here are ways to extend it:
Webhooks and ISR
Strapi fires webhooks on entry.publish, entry.update, and entry.delete. Connect these to Next.js's revalidateTag or revalidatePath endpoint for Incremental Static Regeneration:
// src/app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const secret = req.headers.get("x-webhook-secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const slug = body?.entry?.slug;
if (slug) {
revalidatePath(`/articles/${slug}`);
revalidatePath("/articles");
}
return NextResponse.json({ revalidated: true });
}In Strapi admin, go to Settings → Webhooks and add a webhook pointing to https://your-site.vercel.app/api/revalidate with the header x-webhook-secret: your_secret.
Authentication
Strapi ships with a full JWT authentication system. You can build user registration, login, and protected content:
// Login
const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: "user@example.com", password: "mypassword" }),
});
const { jwt, user } = await res.json();
// Use the JWT as a Bearer token for authenticated requestsCombine with NextAuth.js or Better Auth for a seamless session management experience.
GraphQL API
Install the Strapi GraphQL plugin:
npm install @strapi/plugin-graphqlRegister it in config/plugins.ts:
export default {
graphql: {
config: {
endpoint: "/graphql",
shadowCRUD: true,
playgroundAlways: false,
depthLimit: 7,
amountLimit: 100,
},
},
};Now query your content with GraphQL at http://localhost:1337/graphql — useful for fetching only the fields you need in complex nested queries.
Conclusion
You have built StratoBlog — a fully featured, production-ready headless CMS application using Strapi 5 and Next.js 15 App Router.
Here is what you achieved:
- Modeled three interconnected content types (Article, Category, Author) in Strapi's visual builder
- Configured fine-grained REST API permissions for public access
- Built a typed Next.js API client using
qsfor safe, readable query construction - Created Server Component pages for article listings, category filtering, and article detail views
- Rendered Strapi v5 Blocks rich text with
@strapi/blocks-react-renderer - Implemented Draft Mode for content previewing
- Added i18n support for English and French content
- Optimized images with Next.js Image and Strapi's responsive formats
- Deployed Strapi on Railway with PostgreSQL and S3 media, and the frontend on Vercel
This architecture scales elegantly — Strapi handles all content management with a polished admin experience, while Next.js delivers lightning-fast server-rendered pages. Your editors get a friendly GUI, your developers get full TypeScript safety, and your users get excellent performance.
The full source code for this tutorial is structured to be extended with webhooks, search (Meilisearch), authentication, and comment systems — all common next steps for a real-world blog platform.
Happy building!
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Building a Content-Driven Website with Payload CMS 3 and Next.js
Learn how to build a full-featured content-driven website using Payload CMS 3, which runs natively inside Next.js App Router. This tutorial covers collections, rich text editing, media uploads, authentication, and deploying to production.

Build a Type-Safe GraphQL API with Next.js App Router, Yoga, and Pothos
Learn how to build a fully type-safe GraphQL API using Next.js 15 App Router, GraphQL Yoga, and Pothos schema builder. This hands-on tutorial covers schema design, queries, mutations, authentication middleware, and a React client with urql.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.