Docs-as-code works well until a non-developer needs to fix a typo, schedule a release-day update, or stage three drafts at once. Suddenly, pull requests become a bottleneck, and your technical writers are waiting on engineers to merge a one-line change.
That gap shows up quickly in Git-based documentation workflows: no editor UX, no draft/publish workflow, no scheduling, and no role-based permissions. Every contribution flows through Git, a code editor, and a build pipeline.
By the end of this tutorial, you'll have a Strapi 5 documentation site with structured content on the backend and a Next.js 16 frontend rendering it. Writers get a real editor. Developers keep full control of routing, rendering, and deployment. It separates content from code.
The stack: Strapi 5, Next.js 16 (App Router), SQLite for local dev, deployed to Strapi Cloud and any Node host.
In brief
- Model documentation as structured content in Strapi 5 with a
DocandCategoryCollection Type, then expose it through the REST API. - Build a Next.js 16 App Router frontend that fetches docs at build time and renders a sidebar plus individual doc pages.
- Use the Blocks editor for rich text and
@strapi/blocks-react-rendererfor zero-config rendering in React. - Deploy both layers independently and wire a webhook so publishing a doc triggers a fresh production build.
Why Use Strapi 5 for Documentation
The structural difference is simple: some documentation setups treat docs as Markdown files in a Git repo. Strapi treats docs as structured content with a REST/GraphQL API and an editor UI. That difference ripples through everything.
Three concrete wins with Strapi:
- Editor experience for non-developers. Technical writers, PMs, and DevRel can publish without touching Git, configuring a local dev environment, or learning frontmatter syntax.
- Draft and Publish, scheduling, and roles. Strapi ships Draft & Publish natively, and its Enterprise edition adds Review Workflows for a proper staging and review process instead of a build-only publishing model.
- Multi-channel reuse. The same docs API can power an in-app help widget, a chatbot, or a partner portal, because content lives behind a headless CMS, not inside a static site generator.
If your primary need is versioned docs snapshots or heavy MDX component embedding, a file-based docs setup may still fit better. For everything else, content modeling in Strapi gives you more flexibility.
Workflow matters too. When a technical writer updates a doc in Strapi, the change goes through the Admin Panel's draft and publish cycle. Reviewers can preview changes before they go live. In many Git-based workflows, the same change often goes through a branch, a pull request review, and sometimes a CI/CD pipeline run before it reaches production.
Prerequisites
- Node.js v20, v22, or v24 (active LTS). Odd-numbered Node versions like v23 or v25 are not supported.
- npm or pnpm.
- Basic familiarity with React and REST APIs.
- A code editor and a terminal.
- Optional: a GitHub account if deploying to Strapi Cloud at the end.
Set Up Your Strapi 5 Project
Strapi 5 can scaffold, install, and start a backend, including the Admin Panel from a single CLI command. The setup below produces a local-only project using SQLite, the fastest path for a docs prototype.
Create the Strapi Project
Run the following in your terminal:
npx create-strapi@latest my-strapi-projectThe CLI can guide you through interactive setup options when creating a new project.
- Login/Sign up to Strapi Cloud - skip this for now (you can connect later).
- JavaScript or TypeScript - either works for this tutorial.
- Include example data - select No. You'll create your own doc content types.
- Database selection - accept the default (SQLite). It's the simplest option for local development.
Start the Development Server
cd my-strapi-project
npm run developThe Admin Panel opens at http://localhost:1337/admin. Create your first admin user here. This account is local-only and won't carry over to a production deployment.
Tour the Admin Panel
Two areas matter for this tutorial:
- Content-Type Builder is where you define your schema (Doc, Category, and their fields).
- Content Manager is where you create and publish entries.
One important note: never run npm run start while iterating on schemas. The Content-Type Builder is disabled in production mode. Stick with npm run develop until your content model is stable.
Model Your Documentation Content
A documentation site needs three things: pages, a way to group them into sections, and a way to order them in a sidebar. The schema below maps onto those three needs without overcomplicating the model.
Create the Doc Collection Type
In the Content-Type Builder, click "COLLECTION TYPES +" and create a new type called Doc. Add these fields:
| Field | Type | Configuration |
|---|---|---|
title | Text (Short text) | Required |
slug | UID | Attached field: title |
body | Rich text (Blocks) | The v5 block editor handles headings, code blocks, lists, and links natively |
excerpt | Text (Long text) | Optional, useful for search result previews |
order | Number (Integer) | Controls sidebar position within a category |
The body field uses the Blocks editor, which stores content as a structured JSON tree rather than raw Markdown. This gives the frontend full control over rendering. If your team prefers a Markdown-first workflow, Strapi also ships a Rich Text (Markdown) field type.
Strapi 5 auto-creates documentId, createdAt, and updatedAt on every Collection Type, and publishedAt only when draft & publish is enabled. The locale field is also added automatically, but only when internationalization (i18n) is enabled. You don't need to add these manually.
Create the Category Collection Type
Create a second Collection Type called Category with these fields:
| Field | Type | Configuration |
|---|---|---|
name | Text (Short text) | Required |
slug | UID | Attached field: name |
order | Number (Integer) | Controls top-level sidebar grouping order |
Categories drive the sidebar groupings: "Getting Started," "Guides," "API Reference," and so on.
Add the Doc-to-Category Relation
Open the Doc Collection Type again and add a new Relation field. Select Category as the target. Choose the many-to-one relation type: a Doc has one Category, a Category has many Docs.
In the Content-Type Builder UI, you pick the relation type from six visual options. Select the one where "Doc has one Category" appears on the left and "Category has many Docs" on the right. Save the schema.
This relation matters because it lets the frontend include related docs for a category in a single REST API call using the populate parameter. For a deeper look at how relations work in Strapi 5, see this guide on understanding relations.
Configure Permissions and Seed Sample Docs
Strapi locks all API endpoints by default. To render docs on a public site, the Public role needs read access to the new content types.
Enable Public Read Access
Navigate to Settings → Users & Permissions Plugin → Roles → Public. Find both Doc and Category in the permissions list. Check find and findOne on each. Save.
Verify it works by opening http://localhost:1337 in a browser and confirming the Strapi app is running.
One gotcha worth knowing: the find permission must be enabled on both content types. If Public can read Doc but not Category, populating the category relation silently returns empty data instead of throwing a 403. This trips up a lot of people during development.
Add a Few Sample Docs
Head to Content Manager → Doc → Create new entry. Create at least three docs spread across two categories so you have real data for the frontend. Something like:
- Category: "Getting Started" (order: 1) with docs "Introduction" (order: 1) and "Installation" (order: 2).
- Category: "Guides" (order: 2) with a doc "Authentication" (order: 1).
Click Publish on each entry. When Draft & Publish is enabled, the Strapi 5 API returns the draft version by default; pass status: 'published' to get only published entries.
Note: in Strapi 5, single-document API requests use the documentId (a string), not the database id. Keep this in mind when building frontend fetches.
Common Issues During Setup
- Content-Type Builder greyed out: You ran
npm run startinstead ofnpm run develop. The Content-Type Builder is read-only in production mode. Stop the server and restart withnpm run develop. - Empty response from /api/docs: Your entries exist but were never published. Open Content Manager, select each entry, and click Publish. The REST API only returns published documents by default.
- Category relation returns null: The Public role lacks
findpermission on the Category Collection Type. Go to Settings, Users & Permissions Plugin, Roles, Public, and enable bothfindandfindOneon Category. Without this, populating the relation silently returns empty data rather than an error.
Build the Frontend with Next.js 16
Next.js 16 and Strapi work well together for a docs frontend: file-based routing, server components, and strong static generation support. The setup below uses the App Router and fetches from Strapi at build time so the production site stays fast.
Create the Next.js 16 Project
npx create-next-app@latest docs-frontendSelect App Router when prompted. TypeScript is optional but recommended. Tailwind CSS helps with sidebar styling later.
cd docs-frontend
npm run devConfirm it runs at http://localhost:3000.
Set Up Environment Variables
Create a .env.local file in the project root:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337This variable is prefixed with NEXT_PUBLIC_ because the same URL is used in both server and client components in this tutorial. For a production setup with API token authentication, you'd use a server-only STRAPI_URL variable (no NEXT_PUBLIC_ prefix) alongside a separate STRAPI_TOKEN variable, keeping both out of the browser entirely.
Create a Strapi Fetch Helper
Install the qs package first. Strapi's REST API uses LHS bracket syntax for nested parameters (filters[slug][$eq]=value), and qs handles that serialization cleanly.
npm install qs
npm install -D @types/qsCreate lib/strapi.ts:
import qs from "qs";
const baseUrl = process.env.NEXT_PUBLIC_STRAPI_URL ?? "http://localhost:1337";
export async function fetchStrapi(path: string, queryParams?: object) {
const query = queryParams
? `?${qs.stringify(queryParams, { encodeValuesOnly: true })}`
: "";
const url = `${baseUrl}/api${path}${query}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Strapi request failed: ${res.status} ${res.statusText}`);
}
return res.json();
}Centralizing API calls through a single function gives you one place to add authentication headers, error logging, or caching logic later. In production, you would add an Authorization: Bearer <token> header here using a server-only environment variable.
The function also normalizes Strapi's query parameter format. Without qs, you would need to manually construct bracket-notation strings like filters[slug][$eq]=value for every filtered request.
Fetch All Categories and Their Docs
In app/page.tsx (the docs index), call the helper to load the sidebar data:
import Link from "next/link";
import { fetchStrapi } from "@/lib/strapi";
interface Doc {
documentId: string;
title: string;
slug: string;
order: number;
}
interface Category {
documentId: string;
name: string;
slug: string;
order: number;
docs: Doc[];
}
export default async function HomePage() {
const { data: categories } = await fetchStrapi("/categories", {
populate: {
docs: { fields: ["title", "slug", "order"] },
},
sort: "order:asc",
});
return (
<main className="max-w-3xl mx-auto py-12">
<h1 className="text-3xl font-bold mb-8">Documentation</h1>
{categories.map((category: Category) => (
<section key={category.documentId} className="mb-6">
<h2 className="text-xl font-semibold mb-2">{category.name}</h2>
<ul>
{category.docs
.sort((a: Doc, b: Doc) => a.order - b.order)
.map((doc: Doc) => (
<li key={doc.documentId}>
<Link href={`/docs/${doc.slug}`}>{doc.title}</Link>
</li>
))}
</ul>
</section>
))}
</main>
);
}Strapi 5 does not populate relations by default. The populate parameter is required, or you'll get categories with no docs attached. The response shape is flat in Strapi 5: fields sit directly on each object, with no .attributes wrapper.
Render Documentation Pages and Navigation
A docs site has two repeating UI patterns: a sidebar grouped by category and a single-page route for each doc. Both can be generated from the data already fetched.
Build the Sidebar Component
Create components/Sidebar.tsx:
import Link from "next/link";
import { fetchStrapi } from "@/lib/strapi";
export default async function Sidebar() {
const { data: categories } = await fetchStrapi("/categories", {
populate: {
docs: { fields: ["title", "slug", "order"] },
},
sort: "order:asc",
});
return (
<nav aria-label="Documentation" className="w-64 pr-8">
{categories.map((category: any) => (
<div key={category.documentId} className="mb-4">
<h3 className="font-semibold text-sm uppercase tracking-wide">
{category.name}
</h3>
<ul className="mt-1 space-y-1">
{category.docs
.sort((a: any, b: any) => a.order - b.order)
.map((doc: any) => (
<li key={doc.documentId}>
<Link href={`/docs/${doc.slug}`}>{doc.title}</Link>
</li>
))}
</ul>
</div>
))}
</nav>
);
}This is a server component. It maps over categories, then over each category's docs, rendering linked items. The <nav> element with aria-label keeps it accessible for screen readers.
Generate Dynamic Doc Pages
Create app/docs/[slug]/page.tsx. The generateStaticParams function pre-renders every published doc at build time:
import qs from "qs";
import { fetchStrapi } from "@/lib/strapi";
import Sidebar from "@/components/Sidebar";
import BlockRendererClient from "@/components/BlockRendererClient";
import { type BlocksContent } from "@strapi/blocks-react-renderer";
export async function generateStaticParams() {
const { data } = await fetchStrapi("/docs", {
fields: ["slug"],
});
return data.map((doc: { slug: string }) => ({
slug: doc.slug,
}));
}
export default async function DocPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const query = qs.stringify(
{ filters: { slug: { $eq: slug } } },
{ encodeValuesOnly: true }
);
const res = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/docs?${query}`
);
const { data } = await res.json();
const doc = data[0];
const content: BlocksContent = doc.body;
return (
<div className="flex max-w-5xl mx-auto py-12">
<Sidebar />
<article className="flex-1 max-w-prose">
<h1 className="text-3xl font-bold mb-4">{doc.title}</h1>
<BlockRendererClient content={content} />
</article>
</div>
);
}Deploy Your Documentation Site
The Strapi backend and the Next.js 16 frontend deploy independently. The Strapi instance becomes the source of truth; the Next.js site rebuilds whenever docs change.
Deploy Strapi to Strapi Cloud
Push the Strapi project to a GitHub repo. Then log in to cloud.strapi.io, connect the repo, and configure:
- Select the
mainbranch and your preferred region. - Add the required environment variables:
APP_KEYS,API_TOKEN_SALT,ADMIN_JWT_SECRET, andJWT_SECRET. - Strapi Cloud auto-provisions PostgreSQL, so you don't need to set up a separate database. Install the
pgdriver in your project (npm install pg) and push the updatedpackage.jsonbefore deploying.
Strapi Cloud handles security, managed scaling with DevOps support, and the database. Your backend will be available at a URL like https://your-project.strapiapp.com.
Deploy Next.js 16 and Connect It to Strapi
Push the Next.js project to GitHub. Deploy via Vercel, Netlify, or any Node host. In your host's environment variable settings, set NEXT_PUBLIC_STRAPI_URL to the live Strapi Cloud URL.
On Vercel: import the repo, confirm the Next.js preset, add your environment variables, and deploy. On Netlify: connect the repo and add the same variable under Site Settings → Environment Variables. Netlify automatically configures Next.js projects out of the box.
After the first deploy, update the CORS settings in your Strapi Cloud project. Go to Settings, Security, CORS in the Strapi Admin Panel and add your frontend's production URL (for example, https://docs.yoursite.com) to the allowed origins list. Without this step, browser-based API requests from your frontend will fail with CORS errors. Server-side requests during static generation are unaffected by CORS, but client-side fetches in the browser may fail if the target server does not allow that origin.
Set Up Rebuild Webhooks
In Strapi admin, go to Settings → Webhooks → Create new webhook. Set the URL to the deploy hook from your Next.js 16 host (on Vercel: Settings → Git → Deploy Hooks; on Netlify: Build & Deploy → Build hooks). Select entry.publish and entry.update as trigger events.
Now publishing or updating a doc in Strapi auto-triggers a fresh production build. You can verify it works with the Trigger button in the webhook settings.
For a deeper look at deployment options, the SSG with Strapi webhooks and Next.js 16 guide covers the full webhook lifecycle.
Next Steps for a Production Documentation Site
The build above is the minimum viable docs site. Production-grade docs usually add a few more layers:
- Search. Integrate Algolia DocSearch or Meilisearch with your Strapi project to power search.
- Versioning. Add a
versionfield on the Doc Collection Type and filter by it in the frontend. - Internationalization. Strapi's i18n plugin handles locale-specific content natively. See the guide on building multi-language sites for the approach.
- Custom components in body. Extend the Blocks renderer with callouts, tabs, or API reference blocks using the
blocksprop. - Role-based editing. Give DevRel and engineering different permissions on different categories through Strapi's admin roles.
Every one of these is an iteration on the project you just built, not a rewrite. The content model and API layer stay the same. You just add fields, filters, or renderer customizations on top.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.