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
Doc and Category Collection Type, then expose it through the REST API. @strapi/blocks-react-renderer for zero-config rendering in React. 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:
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.
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.
Run the following in your terminal:
1npx create-strapi@latest my-strapi-projectThe CLI can guide you through interactive setup options when creating a new project.
1cd my-strapi-project
2npm 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.
Two areas matter for this tutorial:
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.
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.
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 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.
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.
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.
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.
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:
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.
npm run start instead of npm run develop. The Content-Type Builder is read-only in production mode. Stop the server and restart with npm run develop. find permission on the Category Collection Type. Go to Settings, Users & Permissions Plugin, Roles, Public, and enable both find and findOne on Category. Without this, populating the relation silently returns empty data rather than an error.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.
1npx create-next-app@latest docs-frontendSelect App Router when prompted. TypeScript is optional but recommended. Tailwind CSS helps with sidebar styling later.
1cd docs-frontend
2npm run devConfirm it runs at http://localhost:3000.
Create a .env.local file in the project root:
1NEXT_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.
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.
1npm install qs
2npm install -D @types/qsCreate lib/strapi.ts:
1import qs from "qs";
2
3const baseUrl = process.env.NEXT_PUBLIC_STRAPI_URL ?? "http://localhost:1337";
4
5export async function fetchStrapi(path: string, queryParams?: object) {
6 const query = queryParams
7 ? `?${qs.stringify(queryParams, { encodeValuesOnly: true })}`
8 : "";
9 const url = `${baseUrl}/api${path}${query}`;
10
11 const res = await fetch(url);
12 if (!res.ok) {
13 throw new Error(`Strapi request failed: ${res.status} ${res.statusText}`);
14 }
15 return res.json();
16}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.
In app/page.tsx (the docs index), call the helper to load the sidebar data:
1import Link from "next/link";
2import { fetchStrapi } from "@/lib/strapi";
3
4interface Doc {
5 documentId: string;
6 title: string;
7 slug: string;
8 order: number;
9}
10
11interface Category {
12 documentId: string;
13 name: string;
14 slug: string;
15 order: number;
16 docs: Doc[];
17}
18
19export default async function HomePage() {
20 const { data: categories } = await fetchStrapi("/categories", {
21 populate: {
22 docs: { fields: ["title", "slug", "order"] },
23 },
24 sort: "order:asc",
25 });
26
27 return (
28 <main className="max-w-3xl mx-auto py-12">
29 <h1 className="text-3xl font-bold mb-8">Documentation</h1>
30 {categories.map((category: Category) => (
31 <section key={category.documentId} className="mb-6">
32 <h2 className="text-xl font-semibold mb-2">{category.name}</h2>
33 <ul>
34 {category.docs
35 .sort((a: Doc, b: Doc) => a.order - b.order)
36 .map((doc: Doc) => (
37 <li key={doc.documentId}>
38 <Link href={`/docs/${doc.slug}`}>{doc.title}</Link>
39 </li>
40 ))}
41 </ul>
42 </section>
43 ))}
44 </main>
45 );
46}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.
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.
Create components/Sidebar.tsx:
1import Link from "next/link";
2import { fetchStrapi } from "@/lib/strapi";
3
4export default async function Sidebar() {
5 const { data: categories } = await fetchStrapi("/categories", {
6 populate: {
7 docs: { fields: ["title", "slug", "order"] },
8 },
9 sort: "order:asc",
10 });
11
12 return (
13 <nav aria-label="Documentation" className="w-64 pr-8">
14 {categories.map((category: any) => (
15 <div key={category.documentId} className="mb-4">
16 <h3 className="font-semibold text-sm uppercase tracking-wide">
17 {category.name}
18 </h3>
19 <ul className="mt-1 space-y-1">
20 {category.docs
21 .sort((a: any, b: any) => a.order - b.order)
22 .map((doc: any) => (
23 <li key={doc.documentId}>
24 <Link href={`/docs/${doc.slug}`}>{doc.title}</Link>
25 </li>
26 ))}
27 </ul>
28 </div>
29 ))}
30 </nav>
31 );
32}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.
Create app/docs/[slug]/page.tsx. The generateStaticParams function pre-renders every published doc at build time:
1import qs from "qs";
2import { fetchStrapi } from "@/lib/strapi";
3import Sidebar from "@/components/Sidebar";
4import BlockRendererClient from "@/components/BlockRendererClient";
5import { type BlocksContent } from "@strapi/blocks-react-renderer";
6
7export async function generateStaticParams() {
8 const { data } = await fetchStrapi("/docs", {
9 fields: ["slug"],
10 });
11
12 return data.map((doc: { slug: string }) => ({
13 slug: doc.slug,
14 }));
15}
16
17export default async function DocPage({
18 params,
19}: {
20 params: Promise<{ slug: string }>;
21}) {
22 const { slug } = await params;
23
24 const query = qs.stringify(
25 { filters: { slug: { $eq: slug } } },
26 { encodeValuesOnly: true }
27 );
28
29 const res = await fetch(
30 `${process.env.NEXT_PUBLIC_STRAPI_URL}/api/docs?${query}`
31 );
32 const { data } = await res.json();
33 const doc = data[0];
34 const content: BlocksContent = doc.body;
35
36 return (
37 <div className="flex max-w-5xl mx-auto py-12">
38 <Sidebar />
39 <article className="flex-1 max-w-prose">
40 <h1 className="text-3xl font-bold mb-4">{doc.title}</h1>
41 <BlockRendererClient content={content} />
42 </article>
43 </div>
44 );
45}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.
Push the Strapi project to a GitHub repo. Then log in to cloud.strapi.io, connect the repo, and configure:
main branch and your preferred region. APP_KEYS, API_TOKEN_SALT, ADMIN_JWT_SECRET, and JWT_SECRET. pg driver in your project (npm install pg) and push the updated package.json before 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.
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.
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.
The build above is the minimum viable docs site. Production-grade docs usually add a few more layers:
version field on the Doc Collection Type and filter by it in the frontend. blocks prop. 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.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.