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

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

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:

FeatureStrapi 5Payload CMS 3DirectusSanityWordPress (headless)
ArchitectureStandalone serverInside Next.jsStandalone serverHosted SaaSSeparate server
Admin UICustom React appNext.js routesVue-basedStudio (React)Classic dashboard
DatabasePostgres/MySQL/SQLitePostgres/MongoDBPostgres/MySQLHostedMySQL
TypeScriptFull (v5)FullPartialFullPartial
Self-hostedYesYesYesNo (paid)Yes
REST APIAuto-generatedAuto-generatedAuto-generatedNo (GROQ)Via plugins
GraphQLPluginBuilt-inBuilt-inNativeVia WPGraphQL
Open-sourceYes (MIT)Yes (MIT)Yes (BSL)PartialYes (GPL)
Content modelingGUI + APICode-firstGUISchema + GROQPHP + GUI
i18nBuilt-in pluginBuilt-inBuilt-inBuilt-inVia plugins
Draft/PublishBuilt-inBuilt-inYesYesBuilt-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-cms

The 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 develop

Strapi 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:

FieldTypeOptions
nameText (Short)Required, Unique
slugUIDAttached to name, Required
descriptionText (Long)Optional
colorText (Short)e.g. #3B82F6

Click Save and wait for Strapi to restart.

Create the Author Collection

Create another collection type named Author:

FieldTypeOptions
nameText (Short)Required
bioText (Long)Optional
avatarMedia (Single image)Optional
twitterText (Short)Optional
githubText (Short)Optional

Create the Article Collection

Now create the main Article collection type with these fields:

FieldTypeOptions
titleText (Short)Required
slugUIDAttached to title, Required
summaryText (Long)Required
contentRich Text (Blocks)Required
coverImageMedia (Single image)Optional
readingTimeNumber (Integer)Optional
publishedDateDateOptional
featuredBooleanDefault false
seoTitleText (Short)Optional
seoDescriptionText (Long)Optional

After creating these fields, add relations:

  1. Click Add another field and choose Relation
  2. Article has many Categories: choose Article belongs to many Category
  3. Add another relation: Article belongs to one Author

Your final Article content type has:

  • All the basic fields above
  • A categories relation (many-to-many with Category)
  • An author relation (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:

  1. Create 2-3 Categories (e.g. "React", "DevOps", "TypeScript")
  2. Create 1-2 Authors with names and bios
  3. 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):

  • find
  • findOne

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 stratoblog

Install 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 frontend
  • qs — 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_2026

To 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.

  1. Push your stratoblog-cms folder to a GitHub repository
  2. Go to railway.app and click New Project → Deploy from GitHub repo
  3. Add a PostgreSQL service to the project
  4. 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-name

Generate secrets with:

node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
  1. Update config/database.ts to 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),
    },
  };
};
  1. 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-s3

Update 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

  1. Push stratoblog (Next.js) to GitHub
  2. Go to vercel.com and import the repository
  3. 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
  1. 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:

  1. 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.
  2. Missing remotePatterns in next.config.ts — add the S3 bucket or CDN hostname to remotePatterns.
  3. Relative URLs — ensure getStrapiMediaUrl prepends 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 find is 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 requests

Combine with NextAuth.js or Better Auth for a seamless session management experience.

GraphQL API

Install the Strapi GraphQL plugin:

npm install @strapi/plugin-graphql

Register 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 qs for 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!


Want to read more tutorials? Check out our latest tutorial on Build Your Own Code Interpreter with Dynamic Tool Generation.

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.

30 min read·