Support teams juggle two very different content problems at once. One side is editorial: help articles that need to rank in search so customers can self-serve before they ever open a ticket. The other side is transactional: tickets with status workflows, ownership, and audit trails. Most teams reach for two separate tools and pay the integration tax forever.
You can build a help desk and customer support portal with Strapi and Next.js that handles both surfaces from a single backend. Strapi 5 models the knowledge base and the ticketing system side by side, enforces status rules with Document Service middlewares, and fires webhooks when tickets change. Next.js renders the public, SEO-friendly articles and the authenticated dashboard from the same codebase.
In brief:
The end product is a customer support portal with two distinct surfaces. The first is a public knowledge base: SEO-optimized article pages authored with Strapi's Blocks editor and gated by Draft & Publish, so a customer searching "how to reset password" lands on an indexed page. The second is an authenticated ticket system where signed-in users submit tickets, track status, and post follow-up replies.
Strapi 5 does the heavy lifting on the backend. It handles content modeling for both surfaces, validates ticket status transitions through Document Service middlewares (so nobody jumps a ticket straight from "open" to "closed"), and emits webhooks on status changes for downstream email notifications. Next.js 16 renders both surfaces using route groups: one group for public content, one for the authenticated dashboard, each with its own layout.
What you'll learn:
Pin these versions exactly. The JavaScript ecosystem moves fast, and mismatched majors cause subtle breakage.
The backend models two content domains in a single Strapi instance. The editorial knowledge base lives alongside the transactional ticket system, sharing one admin panel and one API.
The knowledge base uses Draft & Publish so editors get a staging area before articles go live, while tickets use a status workflow enforced in Document Service middleware so the data stays consistent no matter where an update originates. Webhooks then connect every ticket status change to outside services, which is how email notifications reach customers without polling.
Start by scaffolding the project. The --quickstart flag is deprecated in Strapi 5, so leave it out and let the installer prompt you for a database.
1npx create-strapi@latest support-backendThe installer asks which database to use. Pick PostgreSQL and provide your connection details. If you prefer to configure the database by hand, the connection lives in config/database.ts (or config/database.js for JavaScript projects):
1// config/database.ts
2export default ({ env }) => ({
3 connection: {
4 client: 'postgres',
5 connection: {
6 connectionString: env('DATABASE_URL'),
7 host: env('DATABASE_HOST', 'localhost'),
8 port: env.int('DATABASE_PORT', 5432),
9 database: env('DATABASE_NAME', 'strapi'),
10 user: env('DATABASE_USERNAME', 'strapi'),
11 password: env('DATABASE_PASSWORD', 'strapi'),
12 schema: env('DATABASE_SCHEMA', 'public'),
13 ssl: env.bool('DATABASE_SSL', false) && {
14 rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false),
15 },
16 },
17 pool: {
18 min: env.int('DATABASE_POOL_MIN', 2),
19 max: env.int('DATABASE_POOL_MAX', 10),
20 },
21 },
22});One PostgreSQL gotcha: a fresh database user needs SCHEMA permissions. The database admin has them by default, but a user created specifically for Strapi will not, and you'll hit a 500 error loading the Admin Panel. Grant SCHEMA permissions before you start. Once installed, run npm run develop and create your admin account.
You can build Content-Types through the Content-Type Builder in the Admin Panel, but defining the schema by hand makes the structure explicit. Start with the category.
Create the file at src/api/kb-category/content-types/kb-category/schema.json:
1{
2 "kind": "collectionType",
3 "collectionName": "kb_categories",
4 "info": {
5 "singularName": "kb-category",
6 "pluralName": "kb-categories",
7 "displayName": "KB Category"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "name": { "type": "string", "required": true },
14 "slug": { "type": "uid", "targetField": "name" },
15 "description": { "type": "text" },
16 "icon": { "type": "string" },
17 "articles": {
18 "type": "relation",
19 "relation": "oneToMany",
20 "target": "api::kb-article.kb-article",
21 "mappedBy": "category"
22 }
23 }
24}The uid field generates a URL-safe slug from the name field. Now the article itself, at src/api/kb-article/content-types/kb-article/schema.json:
1{
2 "kind": "collectionType",
3 "collectionName": "kb_articles",
4 "info": {
5 "singularName": "kb-article",
6 "pluralName": "kb-articles",
7 "displayName": "KB Article"
8 },
9 "options": {
10 "draftAndPublish": true
11 },
12 "attributes": {
13 "title": { "type": "string", "required": true },
14 "slug": { "type": "uid", "targetField": "title" },
15 "excerpt": { "type": "text" },
16 "content": { "type": "blocks" },
17 "category": {
18 "type": "relation",
19 "relation": "manyToOne",
20 "target": "api::kb-category.kb-category",
21 "inversedBy": "articles"
22 }
23 }
24}Two things matter here. The content field uses blocks, the Blocks editor introduced as a distinct field type in Strapi. Unlike richtext (which stores Markdown), the Blocks editor stores structured JSON, which makes rendering predictable on the frontend.
And draftAndPublish is set to true, so unpublished drafts stay invisible to the public REST API by default. That gives editors a staging area before an article goes live.
The ticket is the transactional half of the portal. Create src/api/ticket/content-types/ticket/schema.json:
1{
2 "kind": "collectionType",
3 "collectionName": "tickets",
4 "info": {
5 "singularName": "ticket",
6 "pluralName": "tickets",
7 "displayName": "Ticket"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "subject": { "type": "string", "required": true },
14 "description": { "type": "text", "required": true },
15 "status": {
16 "type": "enumeration",
17 "enum": ["open", "in_progress", "awaiting_reply", "resolved", "closed"],
18 "default": "open"
19 },
20 "priority": {
21 "type": "enumeration",
22 "enum": ["low", "medium", "high", "urgent"],
23 "default": "medium"
24 },
25 "resolvedAt": { "type": "datetime" },
26 "lastUpdated": { "type": "datetime" },
27 "submitter": {
28 "type": "relation",
29 "relation": "manyToOne",
30 "target": "plugin::users-permissions.user"
31 },
32 "assignedAgent": {
33 "type": "relation",
34 "relation": "manyToOne",
35 "target": "plugin::users-permissions.user"
36 },
37 "replies": {
38 "type": "relation",
39 "relation": "oneToMany",
40 "target": "api::reply.reply",
41 "mappedBy": "ticket"
42 }
43 }
44}Tickets don't need Draft & Publish, so it's disabled. The submitter and assignedAgent relations both point at the Users & Permissions user. Add a reply Content-Type for follow-up messages at src/api/reply/content-types/reply/schema.json:
1{
2 "kind": "collectionType",
3 "collectionName": "replies",
4 "info": {
5 "singularName": "reply",
6 "pluralName": "replies",
7 "displayName": "Reply"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "message": { "type": "text", "required": true },
14 "author": {
15 "type": "relation",
16 "relation": "manyToOne",
17 "target": "plugin::users-permissions.user"
18 },
19 "ticket": {
20 "type": "relation",
21 "relation": "manyToOne",
22 "target": "api::ticket.ticket",
23 "inversedBy": "replies"
24 }
25 }
26}Restart Strapi after adding schema files so it picks up the new Content-Types.
Here's where Strapi 5 changes how you think about business logic. In Strapi 4 you might reach for lifecycle hooks, but lifecycle hooks fire multiple times per Document Service call in Strapi 5.
A single update can trigger beforeCreate, afterCreate, beforeUpdate, afterUpdate, and more, sometimes once per locale. That double-firing makes them unreliable for validation. Document Service middlewares are the recommended hook point because they run once per Document Service call.
Register middlewares inside the register() function in src/index.ts. The first middleware validates status transitions against an allowed-transitions map. The second auto-sets lastUpdated on every ticket update.
1// src/index.ts
2import type { Core } from '@strapi/strapi';
3
4const TICKET_UID = 'api::ticket.ticket';
5
6const ALLOWED_TRANSITIONS: Record<string, string[]> = {
7 open: ['in_progress', 'closed'],
8 in_progress: ['awaiting_reply', 'resolved', 'closed'],
9 awaiting_reply: ['in_progress', 'resolved', 'closed'],
10 resolved: ['closed', 'in_progress'],
11 closed: [],
12};
13
14export default {
15 register({ strapi }: { strapi: Core.Strapi }) {
16 strapi.documents.use(async (context, next) => {
17 if (context.uid !== TICKET_UID) {
18 return next();
19 }
20
21 if (context.action === 'update') {
22 const nextStatus = context.params.data?.status as string | undefined;
23
24 if (nextStatus) {
25 const documentId = context.params.documentId as string;
26 const current = await strapi.documents(TICKET_UID).findOne({
27 documentId,
28 fields: ['status'],
29 });
30
31 const currentStatus = current?.status;
32
33 if (currentStatus && currentStatus !== nextStatus) {
34 const allowed = ALLOWED_TRANSITIONS[currentStatus] ?? [];
35 if (!allowed.includes(nextStatus)) {
36 throw new Error(
37 `Invalid status transition from "${currentStatus}" to "${nextStatus}".`
38 );
39 }
40 }
41
42 if (nextStatus === 'resolved' && !context.params.data.resolvedAt) {
43 context.params.data.resolvedAt = new Date().toISOString();
44 }
45 }
46 }
47
48 return next();
49 });
50
51 strapi.documents.use((context, next) => {
52 if (context.uid !== TICKET_UID) {
53 return next();
54 }
55
56 if (context.action === 'update') {
57 context.params.data = {
58 ...context.params.data,
59 lastUpdated: new Date().toISOString(),
60 };
61 }
62
63 return next();
64 });
65 },
66
67 bootstrap() {},
68};The transition map enforces the workflow: a ticket can move from open to in_progress or closed, but it cannot jump straight from open to resolved. A closed ticket can't transition anywhere. Mutating context.params.data before calling next() changes what gets written to the database, which is how the lastUpdated and resolvedAt stamps land automatically. Both middlewares scope themselves to the ticket UID and call next() early for everything else.
Order matters here. The validation middleware registers first so it rejects an illegal transition before the stamping middleware ever writes lastUpdated. If the validation middleware throws an Error, it aborts the entire Document Service call, so no partial write reaches the database: the ticket keeps its previous status and no stamp is applied.
That all-or-nothing behavior is what makes middlewares more predictable than lifecycle hooks. With lifecycle hooks, the same checks scattered across beforeUpdate and afterUpdate can fire several times for a single operation, leaving you to guess which invocation owns the write. A single middleware chain that runs once per call gives you one clear place to reason about transitions and computed fields.
Webhooks in Strapi 5 let you notify an external service when content changes. For a help desk, you want an email to go out when a ticket's status changes.
In the Admin Panel, go to Settings → Global Settings → Webhooks and create a new webhook. Give it a name, point the URL at your notification endpoint, and subscribe to the entry.update event for the Ticket entry. Add an authentication header so your endpoint can verify the request is genuine.
The entry.update payload looks like this:
1{
2 "event": "entry.update",
3 "createdAt": "2026-06-15T08:58:26.563Z",
4 "model": "ticket",
5 "entry": {
6 "id": 1,
7 "documentId": "h90lgohlzfpjf3bvan72mzll",
8 "subject": "Login button broken",
9 "status": "in_progress",
10 "priority": "high",
11 "createdAt": "2026-06-14T08:47:36.264Z",
12 "updatedAt": "2026-06-15T08:58:26.210Z"
13 }
14}The top-level fields are event, createdAt, model, and entry. Your receiving endpoint reads entry.status and decides whether to send mail. Since entry.update fires on any field change, filter inside your handler to fire only when the status actually changed: compare the incoming status against the last status you stored.
Two things to flag. First, Strapi 5 documentation does not describe a create/update/publish versus delete/unpublish split for populated relations; instead, its migration notes say the webhooks.populateRelations option was removed and mention create, update, and delete operations in the context of returned relations being populated.
If your email handler needs the submitter's address and the event is a delete or unpublish, fetch it explicitly from the Strapi API inside the handler using the documentId. Second, webhooks do not fire for the User Content-Type. If you ever need to react to user changes, use a lifecycle hook in ./src/index.ts instead.
You can also set default headers for every webhook in config/server.ts:
1// config/server.ts
2export default ({ env }) => ({
3 host: env('HOST', '0.0.0.0'),
4 port: env.int('PORT', 1337),
5 app: {
6 keys: env.array('APP_KEYS'),
7 },
8 webhooks: {
9 defaultHeaders: {
10 'X-Webhook-Source': 'strapi-support',
11 },
12 },
13});Headers set on an individual webhook in the Admin Panel override these defaults.
Before moving on, set permissions. Under Settings → Users & Permissions → Roles → Authenticated, enable find, findOne, and create for Ticket and Reply, and find/findOne for KB Article and KB Category. For the Public role, enable only find and findOne on the knowledge base Content-Types.
The frontend splits into two route groups that share one codebase but serve different audiences. The public group renders knowledge base articles as Server Components with full SEO metadata. The authenticated group handles ticket submission, status tracking, and replies behind a session check. Both groups talk to the same Strapi instance through a typed fetch helper that keeps the API token on the server.
Scaffold the frontend in a separate directory:
1npx create-next-app@latest support-frontend --typescript --tailwind --appThe --tailwind flag adds Tailwind CSS styling support during setup. Add your environment variables. Server-only values live without the NEXT_PUBLIC_ prefix, so they never reach the browser bundle:
1# .env.local
2STRAPI_URL=http://localhost:1337
3STRAPI_API_TOKEN=your_read_only_api_tokenGenerate the API token in Strapi under Settings → API Tokens. Use a read-only token for public content fetching. serverRuntimeConfig and publicRuntimeConfig were removed in Next.js 16, so .env files are the only supported approach.
Set up route groups so the public knowledge base and the authenticated dashboard each get their own layout. A folder wrapped in parentheses is omitted from the URL:
1app/
2├── (public)/
3│ ├── layout.tsx
4│ ├── page.tsx
5│ └── kb/
6│ ├── [category]/
7│ │ └── page.tsx
8│ ├── article/
9│ │ └── [slug]/
10│ │ └── page.tsx
11│ └── search/
12│ └── page.tsx
13├── (dashboard)/
14│ ├── layout.tsx
15│ └── tickets/
16│ ├── page.tsx
17│ ├── new/
18│ │ └── page.tsx
19│ └── [id]/
20│ └── page.tsx
21├── lib/
22│ ├── strapi.ts
23│ └── auth.ts
24├── types/
25│ └── strapi.ts
26├── components/
27│ └── BlocksRenderer.tsx
28└── proxy.tsThe parentheses keep the URL clean while letting each area carry its own layout. The public group can wrap articles in a marketing-style header and footer, while the dashboard group renders sidebar navigation and account controls without the public chrome.
solating the authenticated routes under (dashboard) also simplifies the proxy auth check: every protected path shares the /tickets prefix, so a single rule in proxy.ts guards the whole group. Keeping the two surfaces in separate folders means a change to one layout never leaks into the other.
Define your TypeScript types once. Strapi 5 returns a flat response with no data.attributes wrapper, and every entry carries a documentId:
1// types/strapi.ts
2import type { BlocksContent } from '@strapi/blocks-react-renderer';
3
4export interface StrapiEntry {
5 id: number;
6 documentId: string;
7 createdAt: string;
8 updatedAt: string;
9 publishedAt: string | null;
10}
11
12export interface KBCategory extends StrapiEntry {
13 name: string;
14 slug: string;
15 description: string | null;
16 icon: string | null;
17}
18
19export interface KBArticle extends StrapiEntry {
20 title: string;
21 slug: string;
22 excerpt: string | null;
23 content: BlocksContent;
24 category?: KBCategory;
25}
26
27export type TicketStatus =
28 | 'open'
29 | 'in_progress'
30 | 'awaiting_reply'
31 | 'resolved'
32 | 'closed';
33
34export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent';
35
36export interface Reply extends StrapiEntry {
37 message: string;
38}
39
40export interface Ticket extends StrapiEntry {
41 subject: string;
42 description: string;
43 status: TicketStatus;
44 priority: TicketPriority;
45 resolvedAt: string | null;
46 lastUpdated: string | null;
47 replies?: Reply[];
48}
49
50export interface StrapiListResponse<T> {
51 data: T[];
52 meta: {
53 pagination: {
54 page: number;
55 pageSize: number;
56 pageCount: number;
57 total: number;
58 };
59 };
60}
61
62export interface StrapiSingleResponse<T> {
63 data: T;
64 meta: object;
65}A typed fetch helper keeps the rest of the code clean:
1// lib/strapi.ts
2import 'server-only';
3
4export async function fetchStrapi<T>(
5 path: string,
6 init?: RequestInit
7): Promise<T> {
8 const res = await fetch(`${process.env.STRAPI_URL}${path}`, {
9 ...init,
10 headers: {
11 'Content-Type': 'application/json',
12 Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
13 ...init?.headers,
14 },
15 });
16
17 if (!res.ok) {
18 throw new Error(`Strapi fetch failed: ${res.status} ${path}`);
19 }
20
21 return res.json() as Promise<T>;
22}The server-only guard throws a build error if this file ever gets imported into a Client Component, which protects your API token from leaking.
Start with the category landing page. Since data fetching is dynamic by default in Next.js 16, these Server Components hit Strapi on each request unless you opt into caching. Populate is explicit in Strapi 5 (no populate=* in production), so request only the fields you need.
1// app/(public)/page.tsx
2import Link from 'next/link';
3import { fetchStrapi } from '@/lib/strapi';
4import type { KBCategory, StrapiListResponse } from '@/types/strapi';
5
6export default async function HomePage() {
7 const { data: categories } = await fetchStrapi<
8 StrapiListResponse<KBCategory>
9 >('/api/kb-categories?fields[0]=name&fields[1]=slug&fields[2]=description');
10
11 return (
12 <main className="mx-auto max-w-4xl px-4 py-12">
13 <h1 className="text-3xl font-bold">Help Center</h1>
14 <form action="/kb/search" className="mt-6">
15 <input
16 name="q"
17 type="search"
18 placeholder="Search articles..."
19 className="w-full rounded border px-4 py-2"
20 />
21 </form>
22 <div className="mt-10 grid gap-4 sm:grid-cols-2">
23 {categories.map((cat) => (
24 <Link
25 key={cat.documentId}
26 href={`/kb/${cat.slug}`}
27 className="rounded border p-6 hover:bg-gray-50"
28 >
29 <h2 className="text-xl font-semibold">{cat.name}</h2>
30 {cat.description && (
31 <p className="mt-2 text-gray-600">{cat.description}</p>
32 )}
33 </Link>
34 ))}
35 </div>
36 </main>
37 );
38}The category page lists articles filtered by category slug. Note that params is asynchronous in Next.js 16 and must be awaited:
1// app/(public)/kb/[category]/page.tsx
2import Link from 'next/link';
3import { notFound } from 'next/navigation';
4import { fetchStrapi } from '@/lib/strapi';
5import type { KBArticle, StrapiListResponse } from '@/types/strapi';
6
7export default async function CategoryPage({
8 params,
9}: {
10 params: Promise<{ category: string }>;
11}) {
12 const { category } = await params;
13
14 const query =
15 `/api/kb-articles?filters[category][slug][$eq]=${category}` +
16 `&fields[0]=title&fields[1]=slug&fields[2]=excerpt`;
17
18 const { data: articles } =
19 await fetchStrapi<StrapiListResponse<KBArticle>>(query);
20
21 if (articles.length === 0) notFound();
22
23 return (
24 <main className="mx-auto max-w-4xl px-4 py-12">
25 <h1 className="text-2xl font-bold capitalize">{category}</h1>
26 <ul className="mt-6 space-y-4">
27 {articles.map((article) => (
28 <li key={article.documentId}>
29 <Link
30 href={`/kb/article/${article.slug}`}
31 className="text-lg text-blue-600 hover:underline"
32 >
33 {article.title}
34 </Link>
35 {article.excerpt && (
36 <p className="text-gray-600">{article.excerpt}</p>
37 )}
38 </li>
39 ))}
40 </ul>
41 </main>
42 );
43}Because Draft & Publish is enabled on articles, the REST API returns only published entries by default. Drafts stay hidden without any extra filtering. The full article view renders Blocks content and generates SEO metadata. Since published articles are the default response, search engines index exactly what your editors approve.
The SEO payoff comes from where the work happens. Because Server Components render the article HTML on the server, a crawler receives a fully-formed page on first request instead of an empty shell that depends on client-side JavaScript.
The generateMetadata function emits a unique title and description per article, so each page carries accurate tags for search results and link previews. And because Draft & Publish keeps drafts out of the default API response, only content an editor has approved ever reaches the index.
The Blocks editor stores structured JSON, so you render it by walking the block array. The official @strapi/blocks-react-renderer package is available, but test compatibility with your React 19.2.x version before using in production. As a fallback, write a small custom renderer as a Client Component:
1// components/BlocksRenderer.tsx
2'use client';
3
4import type { ReactNode } from 'react';
5
6type TextNode = {
7 type: 'text';
8 text: string;
9 bold?: boolean;
10 italic?: boolean;
11 underline?: boolean;
12 code?: boolean;
13};
14
15type BlockNode = {
16 type: string;
17 level?: number;
18 format?: 'ordered' | 'unordered';
19 children: (TextNode | BlockNode)[];
20};
21
22export type BlocksContent = BlockNode[];
23
24function renderText(node: TextNode, key: number): ReactNode {
25 let el: ReactNode = node.text;
26 if (node.bold) el = <strong key={key}>{el}</strong>;
27 if (node.italic) el = <em key={key}>{el}</em>;
28 if (node.code) el = <code key={key} className="bg-gray-100 px-1">{el}</code>;
29 return <span key={key}>{el}</span>;
30}
31
32function renderChildren(children: (TextNode | BlockNode)[]): ReactNode {
33 return children.map((child, i) =>
34 child.type === 'text'
35 ? renderText(child as TextNode, i)
36 : renderBlock(child as BlockNode, i)
37 );
38}
39
40function renderBlock(block: BlockNode, key: number): ReactNode {
41 switch (block.type) {
42 case 'paragraph':
43 return <p key={key} className="my-4 leading-relaxed">{renderChildren(block.children)}</p>;
44 case 'heading': {
45 const text = renderChildren(block.children);
46 if (block.level === 1) return <h1 key={key} className="text-3xl font-bold">{text}</h1>;
47 if (block.level === 2) return <h2 key={key} className="text-2xl font-semibold">{text}</h2>;
48 if (block.level === 3) return <h3 key={key} className="text-xl font-medium">{text}</h3>;
49 if (block.level === 4) return <h4 key={key} className="text-lg font-medium">{text}</h4>;
50 if (block.level === 5) return <h5 key={key} className="text-base font-medium">{text}</h5>;
51 return <h6 key={key} className="text-sm font-medium">{text}</h6>;
52 }
53 case 'list':
54 return block.format === 'ordered'
55 ? <ol key={key} className="my-4 list-decimal pl-6">{renderChildren(block.children)}</ol>
56 : <ul key={key} className="my-4 list-disc pl-6">{renderChildren(block.children)}</ul>;
57 case 'list-item':
58 return <li key={key}>{renderChildren(block.children)}</li>;
59 case 'quote':
60 return <blockquote key={key} className="border-l-4 pl-4 italic">{renderChildren(block.children)}</blockquote>;
61 default:
62 return null;
63 }
64}
65
66export function BlocksRenderer({ content }: { content: BlocksContent }) {
67 return <>{content.map((b, i) => renderBlock(b, i))}</>;
68}Now the article page wires up the renderer and generateMetadata:
1// app/(public)/kb/article/[slug]/page.tsx
2import type { Metadata } from 'next';
3import { notFound } from 'next/navigation';
4import { fetchStrapi } from '@/lib/strapi';
5import { BlocksRenderer } from '@/components/BlocksRenderer';
6import type { KBArticle, StrapiListResponse } from '@/types/strapi';
7
8async function getArticle(slug: string): Promise<KBArticle | null> {
9 const { data } = await fetchStrapi<StrapiListResponse<KBArticle>>(
10 `/api/kb-articles?filters[slug][$eq]=${slug}&populate[category][fields][0]=name`
11 );
12 return data[0] ?? null;
13}
14
15export async function generateMetadata({
16 params,
17}: {
18 params: Promise<{ slug: string }>;
19}): Promise<Metadata> {
20 const { slug } = await params;
21 const article = await getArticle(slug);
22
23 return {
24 title: article?.title ?? 'Knowledge Base',
25 description: article?.excerpt ?? undefined,
26 };
27}
28
29export default async function ArticlePage({
30 params,
31}: {
32 params: Promise<{ slug: string }>;
33}) {
34 const { slug } = await params;
35 const article = await getArticle(slug);
36
37 if (!article) notFound();
38
39 return (
40 <article className="mx-auto max-w-3xl px-4 py-12">
41 <h1 className="text-4xl font-bold">{article.title}</h1>
42 <div className="mt-8">
43 <BlocksRenderer content={article.content} />
44 </div>
45 </article>
46 );
47}The search bar from the home page posts to a search route that queries Strapi's filtering API with a case-insensitive $containsi operator across multiple fields. Install the qs library first (npm install qs && npm install -D @types/qs), which handles encoding nested filter objects into query strings:
1// app/(public)/kb/search/page.tsx
2import Link from 'next/link';
3import qs from 'qs';
4import { fetchStrapi } from '@/lib/strapi';
5import type { KBArticle, StrapiListResponse } from '@/types/strapi';
6
7export default async function SearchPage({
8 searchParams,
9}: {
10 searchParams: Promise<{ q?: string }>;
11}) {
12 const { q } = await searchParams;
13 const term = q ?? '';
14
15 const query = qs.stringify(
16 {
17 filters: {
18 $or: [
19 { title: { $containsi: term } },
20 { excerpt: { $containsi: term } },
21 ],
22 },
23 fields: ['title', 'slug', 'excerpt'],
24 },
25 { encodeValuesOnly: true }
26 );
27
28 const { data: results } = await fetchStrapi<StrapiListResponse<KBArticle>>(
29 `/api/kb-articles?${query}`
30 );
31
32 return (
33 <main className="mx-auto max-w-3xl px-4 py-12">
34 <h1 className="text-2xl font-bold">Results for “{term}”</h1>
35 <ul className="mt-6 space-y-4">
36 {results.map((article) => (
37 <li key={article.documentId}>
38 <Link href={`/kb/article/${article.slug}`} className="text-blue-600 hover:underline">
39 {article.title}
40 </Link>
41 </li>
42 ))}
43 </ul>
44 </main>
45 );
46}The ticket dashboard sits behind authentication. Protect the routes with proxy.ts, the Next.js 16 replacement for middleware.ts.
1// proxy.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { cookies } from 'next/headers';
4
5const protectedRoutes = ['/tickets'];
6
7export default async function proxy(req: NextRequest) {
8 const path = req.nextUrl.pathname;
9 const isProtected = protectedRoutes.some((r) => path.startsWith(r));
10
11 const session = (await cookies()).get('session')?.value;
12
13 if (isProtected && !session) {
14 return NextResponse.redirect(new URL('/login', req.url));
15 }
16
17 return NextResponse.next();
18}Authentication uses Strapi's Users & Permissions REST API JWT flow. A login Server Action posts credentials to /api/auth/local and retrieves a JWT, which must be stored by the client. By default, Strapi does not store the JWT in an HTTP-only cookie unless custom logic is added.
1// lib/auth.ts
2'use server';
3
4import { cookies } from 'next/headers';
5
6export async function login(formData: FormData) {
7 const identifier = formData.get('email') as string;
8 const password = formData.get('password') as string;
9
10 const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local`, {
11 method: 'POST',
12 headers: { 'Content-Type': 'application/json' },
13 body: JSON.stringify({ identifier, password }),
14 });
15
16 if (!res.ok) return { error: 'Invalid credentials' };
17
18 const { jwt, user } = await res.json();
19 const encryptedSession = JSON.stringify({ jwt, userId: user.documentId });
20
21 (await cookies()).set('session', encryptedSession, {
22 httpOnly: true,
23 secure: process.env.NODE_ENV === 'production',
24 maxAge: 60 * 60 * 24 * 7,
25 path: '/',
26 });
27
28 return { success: true };
29}
30
31export async function getSession() {
32 const raw = (await cookies()).get('session')?.value;
33 if (!raw) return null;
34 return JSON.parse(raw) as { jwt: string; userId: string };
35}Now build the ticket form. A Server Action POSTs the new ticket to Strapi using the authenticated user's JWT, then redirects to the dashboard:
1// app/(dashboard)/tickets/new/page.tsx
2import { redirect } from 'next/navigation';
3import { getSession } from '@/lib/auth';
4
5async function createTicket(formData: FormData) {
6 'use server';
7
8 const session = await getSession();
9 if (!session) throw new Error('Unauthorized');
10
11 const res = await fetch(`${process.env.STRAPI_URL}/api/tickets`, {
12 method: 'POST',
13 headers: {
14 'Content-Type': 'application/json',
15 Authorization: `Bearer ${session.jwt}`,
16 },
17 body: JSON.stringify({
18 data: {
19 subject: formData.get('subject'),
20 description: formData.get('description'),
21 priority: formData.get('priority'),
22 status: 'open',
23 submitter: { connect: [session.userId] },
24 },
25 }),
26 });
27
28 if (!res.ok) throw new Error(`Failed to create ticket: ${res.status}`);
29
30 redirect('/tickets');
31}
32
33export default function NewTicketPage() {
34 return (
35 <main className="mx-auto max-w-xl px-4 py-12">
36 <h1 className="text-2xl font-bold">Submit a Ticket</h1>
37 <form action={createTicket} className="mt-6 space-y-4">
38 <input
39 name="subject"
40 required
41 placeholder="Subject"
42 className="w-full rounded border px-3 py-2"
43 />
44 <textarea
45 name="description"
46 required
47 rows={5}
48 placeholder="Describe your issue"
49 className="w-full rounded border px-3 py-2"
50 />
51 <select name="priority" className="w-full rounded border px-3 py-2">
52 <option value="low">Low</option>
53 <option value="medium">Medium</option>
54 <option value="high">High</option>
55 <option value="urgent">Urgent</option>
56 </select>
57 <button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">
58 Submit Ticket
59 </button>
60 </form>
61 </main>
62 );
63}The submitter relation in Strapi 5 can use the connect syntax to link relations on create and update, but other methods such as set or passing arrays directly are also supported. Notice this is a create, not a lifecycle-driven flow. Strapi's lifecycle hooks fire multiple times per Document Service call, so keep ticket creation logic in the Server Action and the Document Service middleware, not in lifecycle hooks.
The dashboard lists the signed-in user's tickets with status badges. Filter by the submitter relation and sort by most recent:
1// app/(dashboard)/tickets/page.tsx
2import Link from 'next/link';
3import { getSession } from '@/lib/auth';
4import type { Ticket, StrapiListResponse, TicketStatus } from '@/types/strapi';
5
6const STATUS_STYLES: Record<TicketStatus, string> = {
7 open: 'bg-blue-100 text-blue-800',
8 in_progress: 'bg-yellow-100 text-yellow-800',
9 awaiting_reply: 'bg-purple-100 text-purple-800',
10 resolved: 'bg-green-100 text-green-800',
11 closed: 'bg-gray-100 text-gray-800',
12};
13
14export default async function TicketsPage() {
15 const session = await getSession();
16 if (!session) throw new Error('Unauthorized');
17
18 const res = await fetch(
19 `${process.env.STRAPI_URL}/api/tickets?filters[submitter][documentId][$eq]=${session.userId}&sort=lastUpdated:desc`,
20 { headers: { Authorization: `Bearer ${session.jwt}` }, cache: 'no-store' }
21 );
22
23 if (!res.ok) throw new Error(`Failed to load tickets: ${res.status}`);
24
25 const { data: tickets }: StrapiListResponse<Ticket> = await res.json();
26
27 return (
28 <main className="mx-auto max-w-3xl px-4 py-12">
29 <div className="flex items-center justify-between">
30 <h1 className="text-2xl font-bold">My Tickets</h1>
31 <Link href="/tickets/new" className="rounded bg-blue-600 px-4 py-2 text-white">
32 New Ticket
33 </Link>
34 </div>
35 <ul className="mt-6 divide-y">
36 {tickets.map((ticket) => (
37 <li key={ticket.documentId} className="py-4">
38 <Link href={`/tickets/${ticket.documentId}`} className="flex justify-between">
39 <span className="font-medium">{ticket.subject}</span>
40 <span className={`rounded px-2 py-1 text-xs ${STATUS_STYLES[ticket.status]}`}>
41 {ticket.status.replace('_', ' ')}
42 </span>
43 </Link>
44 </li>
45 ))}
46 </ul>
47 </main>
48 );
49}The detail view shows the full ticket with its replies and a form to add a follow-up. The reply Server Action POSTs to the reply Content-Type and connects it to the ticket:
1// app/(dashboard)/tickets/[id]/page.tsx
2import { notFound } from 'next/navigation';
3import { revalidatePath } from 'next/cache';
4import { getSession } from '@/lib/auth';
5import type { Ticket, StrapiSingleResponse } from '@/types/strapi';
6
7async function getTicket(documentId: string, jwt: string): Promise<Ticket | null> {
8 // Note: Strapi does not support using `fields` to select only certain fields within the populated `replies` relation; `fields` applies only to non-relational fields on the root document.
9 const res = await fetch(
10 `${process.env.STRAPI_URL}/api/tickets/${documentId}?populate[replies]=*&fields[0]=message&fields[1]=createdAt`,
11 { headers: { Authorization: `Bearer ${jwt}` }, cache: 'no-store' }
12 );
13 if (!res.ok) return null;
14 const { data }: StrapiSingleResponse<Ticket> = await res.json();
15 return data;
16}
17
18export default async function TicketDetailPage({
19 params,
20}: {
21 params: Promise<{ id: string }>;
22}) {
23 const { id } = await params;
24 const session = await getSession();
25 if (!session) throw new Error('Unauthorized');
26
27 const ticket = await getTicket(id, session.jwt);
28 if (!ticket) notFound();
29
30 async function addReply(formData: FormData) {
31 'use server';
32 const s = await getSession();
33 if (!s) throw new Error('Unauthorized');
34
35 const res = await fetch(`${process.env.STRAPI_URL}/api/replies`, {
36 method: 'POST',
37 headers: {
38 'Content-Type': 'application/json',
39 Authorization: `Bearer ${s.jwt}`,
40 },
41 body: JSON.stringify({
42 data: {
43 message: formData.get('message'),
44 author: { connect: [s.userId] },
45 ticket: { connect: [id] },
46 },
47 }),
48 });
49
50 if (!res.ok) throw new Error(`Failed to add reply: ${res.status}`);
51 revalidatePath(`/tickets/${id}`);
52 }
53
54 return (
55 <main className="mx-auto max-w-2xl px-4 py-12">
56 <h1 className="text-2xl font-bold">{ticket.subject}</h1>
57 <p className="mt-2 text-gray-600">{ticket.description}</p>
58
59 <h2 className="mt-8 text-lg font-semibold">Replies</h2>
60 <ul className="mt-4 space-y-3">
61 {ticket.replies?.map((reply) => (
62 <li key={reply.documentId} className="rounded border p-3">
63 {reply.message}
64 </li>
65 ))}
66 </ul>
67
68 <form action={addReply} className="mt-6 space-y-3">
69 <textarea
70 name="message"
71 required
72 rows={3}
73 placeholder="Add a reply..."
74 className="w-full rounded border px-3 py-2"
75 />
76 <button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">
77 Send Reply
78 </button>
79 </form>
80 </main>
81 );
82}The populate is explicit on replies, requesting only message and createdAt.
Run both servers: npm run develop in the Strapi directory and npm run dev in the Next.js directory.
Start in the Admin Panel. Create a KB Category like "Account & Billing," then create a KB Article titled "How to reset your password." Author the body in the Blocks editor with a heading and a couple of paragraphs, then click Publish. Visit http://localhost:3000, click into the category, open the article, and confirm the Blocks content renders. View the page source: generateMetadata populated the <title> and description, so the page is ready for indexing. Try searching "password" from the home page; the $containsi filter returns the article.
Now switch to the authenticated side. Register a user through Strapi's /api/auth/local/register endpoint (or build a signup form), log in, and submit a ticket with priority "high." It lands in your dashboard with an "open" badge. Open the ticket and post a follow-up reply.
Test the workflow enforcement. In the Admin Panel, open the ticket and try to change its status from "open" directly to "resolved." The Document Service middleware rejects it because that transition isn't in the allowed map. Change it to "in_progress" first, then to "resolved," and watch resolvedAt and lastUpdated get stamped automatically. On that update, the webhook fires. Check your notification endpoint and confirm the payload arrives with event: "entry.update", model: "ticket", and the new status inside entry. That's the full loop: published content on the public surface, validated transactional updates on the authenticated surface, and an outbound notification on every status change.
Strapi 5 turns what would normally be two separate systems into one backend. The Content-Type Builder models both editorial articles and transactional tickets in the same Admin Panel, so your team manages knowledge base content and support workflows without switching tools.
Document Service middlewares enforce ticket status transitions at the API layer, which means the rules hold whether an update comes from the frontend, the Admin Panel, or a third-party integration.
Draft & Publish keeps unfinished articles out of the public API until an editor approves them. And webhooks push status changes to external services in real time, so notification logic stays outside your application code. The result is a single content backend that handles both self-service content and structured workflows. Explore the full feature set on the Strapi features page, or deploy your project to Strapi Cloud.
You have a working dual-surface portal. From here, the logical follow-on work:
$containsi filter can't match.assignedAgent relation and bulk status actions.npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.