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:
- Model knowledge base articles and support tickets as Strapi 5 Content-Types, with the Blocks editor for rich article content.
- Enforce ticket status workflows using Document Service middlewares instead of lifecycle hooks.
- Configure webhooks that fire on ticket updates for email notification integration.
- Build separate public and authenticated route groups in Next.js 16 with Server Components and Server Actions.
What We're Building
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:
- Modeling knowledge base articles and tickets as Strapi 5 Content-Types
- Authoring rich content with Strapi's Blocks editor and rendering it on the frontend
- Enforcing ticket status workflows with Document Service middlewares
- Configuring webhooks for status-change notifications
- Building public and authenticated route groups in Next.js 16
Prerequisites
Pin these versions exactly. The JavaScript ecosystem moves fast, and mismatched majors cause subtle breakage.
- Node.js v22 LTS (see Node.js LTS release; Active LTS; v20 and v22 are both supported)
- Strapi 5.x (this tutorial uses 5.47.0, which was a recent stable release at the time of writing)
- Next.js 16.2.x (App Router, Server Components, Server Actions)
- React 19.2.x (ships with Next.js 16)
- PostgreSQL (17.0 recommended; 14.0 minimum supported)
- Basic familiarity with TypeScript and Next.js TypeScript config, React, and REST APIs
- A code editor and a terminal
Setting Up the Strapi Backend
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.
Step 1: Install Strapi 5
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.
npx 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):
// config/database.ts
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: {
connectionString: env('DATABASE_URL'),
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
schema: env('DATABASE_SCHEMA', 'public'),
ssl: env.bool('DATABASE_SSL', false) && {
rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false),
},
},
pool: {
min: env.int('DATABASE_POOL_MIN', 2),
max: env.int('DATABASE_POOL_MAX', 10),
},
},
});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.
Step 2: Define the Knowledge Base Content-Types
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:
{
"kind": "collectionType",
"collectionName": "kb_categories",
"info": {
"singularName": "kb-category",
"pluralName": "kb-categories",
"displayName": "KB Category"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"name": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "name" },
"description": { "type": "text" },
"icon": { "type": "string" },
"articles": {
"type": "relation",
"relation": "oneToMany",
"target": "api::kb-article.kb-article",
"mappedBy": "category"
}
}
}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:
{
"kind": "collectionType",
"collectionName": "kb_articles",
"info": {
"singularName": "kb-article",
"pluralName": "kb-articles",
"displayName": "KB Article"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "title" },
"excerpt": { "type": "text" },
"content": { "type": "blocks" },
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::kb-category.kb-category",
"inversedBy": "articles"
}
}
}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.
Step 3: Define the Ticket Collection Type
The ticket is the transactional half of the portal. Create src/api/ticket/content-types/ticket/schema.json:
{
"kind": "collectionType",
"collectionName": "tickets",
"info": {
"singularName": "ticket",
"pluralName": "tickets",
"displayName": "Ticket"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"subject": { "type": "string", "required": true },
"description": { "type": "text", "required": true },
"status": {
"type": "enumeration",
"enum": ["open", "in_progress", "awaiting_reply", "resolved", "closed"],
"default": "open"
},
"priority": {
"type": "enumeration",
"enum": ["low", "medium", "high", "urgent"],
"default": "medium"
},
"resolvedAt": { "type": "datetime" },
"lastUpdated": { "type": "datetime" },
"submitter": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"assignedAgent": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"replies": {
"type": "relation",
"relation": "oneToMany",
"target": "api::reply.reply",
"mappedBy": "ticket"
}
}
}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:
{
"kind": "collectionType",
"collectionName": "replies",
"info": {
"singularName": "reply",
"pluralName": "replies",
"displayName": "Reply"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"message": { "type": "text", "required": true },
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"ticket": {
"type": "relation",
"relation": "manyToOne",
"target": "api::ticket.ticket",
"inversedBy": "replies"
}
}
}Restart Strapi after adding schema files so it picks up the new Content-Types.
Step 4: Build Status Transition Middlewares
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.
// src/index.ts
import type { Core } from '@strapi/strapi';
const TICKET_UID = 'api::ticket.ticket';
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
open: ['in_progress', 'closed'],
in_progress: ['awaiting_reply', 'resolved', 'closed'],
awaiting_reply: ['in_progress', 'resolved', 'closed'],
resolved: ['closed', 'in_progress'],
closed: [],
};
export default {
register({ strapi }: { strapi: Core.Strapi }) {
strapi.documents.use(async (context, next) => {
if (context.uid !== TICKET_UID) {
return next();
}
if (context.action === 'update') {
const nextStatus = context.params.data?.status as string | undefined;
if (nextStatus) {
const documentId = context.params.documentId as string;
const current = await strapi.documents(TICKET_UID).findOne({
documentId,
fields: ['status'],
});
const currentStatus = current?.status;
if (currentStatus && currentStatus !== nextStatus) {
const allowed = ALLOWED_TRANSITIONS[currentStatus] ?? [];
if (!allowed.includes(nextStatus)) {
throw new Error(
`Invalid status transition from "${currentStatus}" to "${nextStatus}".`
);
}
}
if (nextStatus === 'resolved' && !context.params.data.resolvedAt) {
context.params.data.resolvedAt = new Date().toISOString();
}
}
}
return next();
});
strapi.documents.use((context, next) => {
if (context.uid !== TICKET_UID) {
return next();
}
if (context.action === 'update') {
context.params.data = {
...context.params.data,
lastUpdated: new Date().toISOString(),
};
}
return next();
});
},
bootstrap() {},
};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.
Step 5: Configure Webhooks for Ticket Notifications
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:
{
"event": "entry.update",
"createdAt": "2026-06-15T08:58:26.563Z",
"model": "ticket",
"entry": {
"id": 1,
"documentId": "h90lgohlzfpjf3bvan72mzll",
"subject": "Login button broken",
"status": "in_progress",
"priority": "high",
"createdAt": "2026-06-14T08:47:36.264Z",
"updatedAt": "2026-06-15T08:58:26.210Z"
}
}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:
// config/server.ts
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
webhooks: {
defaultHeaders: {
'X-Webhook-Source': 'strapi-support',
},
},
});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.
Building the Next.js 16 Frontend
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.
Step 1: Set Up the Next.js 16 Project
Scaffold the frontend in a separate directory:
npx 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:
# .env.local
STRAPI_URL=http://localhost:1337
STRAPI_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:
app/
├── (public)/
│ ├── layout.tsx
│ ├── page.tsx
│ └── kb/
│ ├── [category]/
│ │ └── page.tsx
│ ├── article/
│ │ └── [slug]/
│ │ └── page.tsx
│ └── search/
│ └── page.tsx
├── (dashboard)/
│ ├── layout.tsx
│ └── tickets/
│ ├── page.tsx
│ ├── new/
│ │ └── page.tsx
│ └── [id]/
│ └── page.tsx
├── lib/
│ ├── strapi.ts
│ └── auth.ts
├── types/
│ └── strapi.ts
├── components/
│ └── BlocksRenderer.tsx
└── 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:
// types/strapi.ts
import type { BlocksContent } from '@strapi/blocks-react-renderer';
export interface StrapiEntry {
id: number;
documentId: string;
createdAt: string;
updatedAt: string;
publishedAt: string | null;
}
export interface KBCategory extends StrapiEntry {
name: string;
slug: string;
description: string | null;
icon: string | null;
}
export interface KBArticle extends StrapiEntry {
title: string;
slug: string;
excerpt: string | null;
content: BlocksContent;
category?: KBCategory;
}
export type TicketStatus =
| 'open'
| 'in_progress'
| 'awaiting_reply'
| 'resolved'
| 'closed';
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent';
export interface Reply extends StrapiEntry {
message: string;
}
export interface Ticket extends StrapiEntry {
subject: string;
description: string;
status: TicketStatus;
priority: TicketPriority;
resolvedAt: string | null;
lastUpdated: string | null;
replies?: Reply[];
}
export interface StrapiListResponse<T> {
data: T[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface StrapiSingleResponse<T> {
data: T;
meta: object;
}A typed fetch helper keeps the rest of the code clean:
// lib/strapi.ts
import 'server-only';
export async function fetchStrapi<T>(
path: string,
init?: RequestInit
): Promise<T> {
const res = await fetch(`${process.env.STRAPI_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.STRAPI_API_TOKEN}`,
...init?.headers,
},
});
if (!res.ok) {
throw new Error(`Strapi fetch failed: ${res.status} ${path}`);
}
return res.json() as Promise<T>;
}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.
Step 2: Build the Public Knowledge Base
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.
// app/(public)/page.tsx
import Link from 'next/link';
import { fetchStrapi } from '@/lib/strapi';
import type { KBCategory, StrapiListResponse } from '@/types/strapi';
export default async function HomePage() {
const { data: categories } = await fetchStrapi<
StrapiListResponse<KBCategory>
>('/api/kb-categories?fields[0]=name&fields[1]=slug&fields[2]=description');
return (
<main className="mx-auto max-w-4xl px-4 py-12">
<h1 className="text-3xl font-bold">Help Center</h1>
<form action="/kb/search" className="mt-6">
<input
name="q"
type="search"
placeholder="Search articles..."
className="w-full rounded border px-4 py-2"
/>
</form>
<div className="mt-10 grid gap-4 sm:grid-cols-2">
{categories.map((cat) => (
<Link
key={cat.documentId}
href={`/kb/${cat.slug}`}
className="rounded border p-6 hover:bg-gray-50"
>
<h2 className="text-xl font-semibold">{cat.name}</h2>
{cat.description && (
<p className="mt-2 text-gray-600">{cat.description}</p>
)}
</Link>
))}
</div>
</main>
);
}The category page lists articles filtered by category slug. Note that params is asynchronous in Next.js 16 and must be awaited:
// app/(public)/kb/[category]/page.tsx
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { fetchStrapi } from '@/lib/strapi';
import type { KBArticle, StrapiListResponse } from '@/types/strapi';
export default async function CategoryPage({
params,
}: {
params: Promise<{ category: string }>;
}) {
const { category } = await params;
const query =
`/api/kb-articles?filters[category][slug][$eq]=${category}` +
`&fields[0]=title&fields[1]=slug&fields[2]=excerpt`;
const { data: articles } =
await fetchStrapi<StrapiListResponse<KBArticle>>(query);
if (articles.length === 0) notFound();
return (
<main className="mx-auto max-w-4xl px-4 py-12">
<h1 className="text-2xl font-bold capitalize">{category}</h1>
<ul className="mt-6 space-y-4">
{articles.map((article) => (
<li key={article.documentId}>
<Link
href={`/kb/article/${article.slug}`}
className="text-lg text-blue-600 hover:underline"
>
{article.title}
</Link>
{article.excerpt && (
<p className="text-gray-600">{article.excerpt}</p>
)}
</li>
))}
</ul>
</main>
);
}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:
// components/BlocksRenderer.tsx
'use client';
import type { ReactNode } from 'react';
type TextNode = {
type: 'text';
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
code?: boolean;
};
type BlockNode = {
type: string;
level?: number;
format?: 'ordered' | 'unordered';
children: (TextNode | BlockNode)[];
};
export type BlocksContent = BlockNode[];
function renderText(node: TextNode, key: number): ReactNode {
let el: ReactNode = node.text;
if (node.bold) el = <strong key={key}>{el}</strong>;
if (node.italic) el = <em key={key}>{el}</em>;
if (node.code) el = <code key={key} className="bg-gray-100 px-1">{el}</code>;
return <span key={key}>{el}</span>;
}
function renderChildren(children: (TextNode | BlockNode)[]): ReactNode {
return children.map((child, i) =>
child.type === 'text'
? renderText(child as TextNode, i)
: renderBlock(child as BlockNode, i)
);
}
function renderBlock(block: BlockNode, key: number): ReactNode {
switch (block.type) {
case 'paragraph':
return <p key={key} className="my-4 leading-relaxed">{renderChildren(block.children)}</p>;
case 'heading': {
const text = renderChildren(block.children);
if (block.level === 1) return <h1 key={key} className="text-3xl font-bold">{text}</h1>;
if (block.level === 2) return <h2 key={key} className="text-2xl font-semibold">{text}</h2>;
if (block.level === 3) return <h3 key={key} className="text-xl font-medium">{text}</h3>;
if (block.level === 4) return <h4 key={key} className="text-lg font-medium">{text}</h4>;
if (block.level === 5) return <h5 key={key} className="text-base font-medium">{text}</h5>;
return <h6 key={key} className="text-sm font-medium">{text}</h6>;
}
case 'list':
return block.format === 'ordered'
? <ol key={key} className="my-4 list-decimal pl-6">{renderChildren(block.children)}</ol>
: <ul key={key} className="my-4 list-disc pl-6">{renderChildren(block.children)}</ul>;
case 'list-item':
return <li key={key}>{renderChildren(block.children)}</li>;
case 'quote':
return <blockquote key={key} className="border-l-4 pl-4 italic">{renderChildren(block.children)}</blockquote>;
default:
return null;
}
}
export function BlocksRenderer({ content }: { content: BlocksContent }) {
return <>{content.map((b, i) => renderBlock(b, i))}</>;
}Now the article page wires up the renderer and generateMetadata:
// app/(public)/kb/article/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { fetchStrapi } from '@/lib/strapi';
import { BlocksRenderer } from '@/components/BlocksRenderer';
import type { KBArticle, StrapiListResponse } from '@/types/strapi';
async function getArticle(slug: string): Promise<KBArticle | null> {
const { data } = await fetchStrapi<StrapiListResponse<KBArticle>>(
`/api/kb-articles?filters[slug][$eq]=${slug}&populate[category][fields][0]=name`
);
return data[0] ?? null;
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const article = await getArticle(slug);
return {
title: article?.title ?? 'Knowledge Base',
description: article?.excerpt ?? undefined,
};
}
export default async function ArticlePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const article = await getArticle(slug);
if (!article) notFound();
return (
<article className="mx-auto max-w-3xl px-4 py-12">
<h1 className="text-4xl font-bold">{article.title}</h1>
<div className="mt-8">
<BlocksRenderer content={article.content} />
</div>
</article>
);
}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:
// app/(public)/kb/search/page.tsx
import Link from 'next/link';
import qs from 'qs';
import { fetchStrapi } from '@/lib/strapi';
import type { KBArticle, StrapiListResponse } from '@/types/strapi';
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const term = q ?? '';
const query = qs.stringify(
{
filters: {
$or: [
{ title: { $containsi: term } },
{ excerpt: { $containsi: term } },
],
},
fields: ['title', 'slug', 'excerpt'],
},
{ encodeValuesOnly: true }
);
const { data: results } = await fetchStrapi<StrapiListResponse<KBArticle>>(
`/api/kb-articles?${query}`
);
return (
<main className="mx-auto max-w-3xl px-4 py-12">
<h1 className="text-2xl font-bold">Results for “{term}”</h1>
<ul className="mt-6 space-y-4">
{results.map((article) => (
<li key={article.documentId}>
<Link href={`/kb/article/${article.slug}`} className="text-blue-600 hover:underline">
{article.title}
</Link>
</li>
))}
</ul>
</main>
);
}Step 3: Build the Ticket Submission Form
The ticket dashboard sits behind authentication. Protect the routes with proxy.ts, the Next.js 16 replacement for middleware.ts.
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const protectedRoutes = ['/tickets'];
export default async function proxy(req: NextRequest) {
const path = req.nextUrl.pathname;
const isProtected = protectedRoutes.some((r) => path.startsWith(r));
const session = (await cookies()).get('session')?.value;
if (isProtected && !session) {
return NextResponse.redirect(new URL('/login', req.url));
}
return NextResponse.next();
}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.
// lib/auth.ts
'use server';
import { cookies } from 'next/headers';
export async function login(formData: FormData) {
const identifier = formData.get('email') as string;
const password = formData.get('password') as string;
const res = await fetch(`${process.env.STRAPI_URL}/api/auth/local`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
});
if (!res.ok) return { error: 'Invalid credentials' };
const { jwt, user } = await res.json();
const encryptedSession = JSON.stringify({ jwt, userId: user.documentId });
(await cookies()).set('session', encryptedSession, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7,
path: '/',
});
return { success: true };
}
export async function getSession() {
const raw = (await cookies()).get('session')?.value;
if (!raw) return null;
return JSON.parse(raw) as { jwt: string; userId: string };
}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:
// app/(dashboard)/tickets/new/page.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
async function createTicket(formData: FormData) {
'use server';
const session = await getSession();
if (!session) throw new Error('Unauthorized');
const res = await fetch(`${process.env.STRAPI_URL}/api/tickets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.jwt}`,
},
body: JSON.stringify({
data: {
subject: formData.get('subject'),
description: formData.get('description'),
priority: formData.get('priority'),
status: 'open',
submitter: { connect: [session.userId] },
},
}),
});
if (!res.ok) throw new Error(`Failed to create ticket: ${res.status}`);
redirect('/tickets');
}
export default function NewTicketPage() {
return (
<main className="mx-auto max-w-xl px-4 py-12">
<h1 className="text-2xl font-bold">Submit a Ticket</h1>
<form action={createTicket} className="mt-6 space-y-4">
<input
name="subject"
required
placeholder="Subject"
className="w-full rounded border px-3 py-2"
/>
<textarea
name="description"
required
rows={5}
placeholder="Describe your issue"
className="w-full rounded border px-3 py-2"
/>
<select name="priority" className="w-full rounded border px-3 py-2">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">
Submit Ticket
</button>
</form>
</main>
);
}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.
Step 4: Create the Ticket Dashboard and Detail View
The dashboard lists the signed-in user's tickets with status badges. Filter by the submitter relation and sort by most recent:
// app/(dashboard)/tickets/page.tsx
import Link from 'next/link';
import { getSession } from '@/lib/auth';
import type { Ticket, StrapiListResponse, TicketStatus } from '@/types/strapi';
const STATUS_STYLES: Record<TicketStatus, string> = {
open: 'bg-blue-100 text-blue-800',
in_progress: 'bg-yellow-100 text-yellow-800',
awaiting_reply: 'bg-purple-100 text-purple-800',
resolved: 'bg-green-100 text-green-800',
closed: 'bg-gray-100 text-gray-800',
};
export default async function TicketsPage() {
const session = await getSession();
if (!session) throw new Error('Unauthorized');
const res = await fetch(
`${process.env.STRAPI_URL}/api/tickets?filters[submitter][documentId][$eq]=${session.userId}&sort=lastUpdated:desc`,
{ headers: { Authorization: `Bearer ${session.jwt}` }, cache: 'no-store' }
);
if (!res.ok) throw new Error(`Failed to load tickets: ${res.status}`);
const { data: tickets }: StrapiListResponse<Ticket> = await res.json();
return (
<main className="mx-auto max-w-3xl px-4 py-12">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">My Tickets</h1>
<Link href="/tickets/new" className="rounded bg-blue-600 px-4 py-2 text-white">
New Ticket
</Link>
</div>
<ul className="mt-6 divide-y">
{tickets.map((ticket) => (
<li key={ticket.documentId} className="py-4">
<Link href={`/tickets/${ticket.documentId}`} className="flex justify-between">
<span className="font-medium">{ticket.subject}</span>
<span className={`rounded px-2 py-1 text-xs ${STATUS_STYLES[ticket.status]}`}>
{ticket.status.replace('_', ' ')}
</span>
</Link>
</li>
))}
</ul>
</main>
);
}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:
// app/(dashboard)/tickets/[id]/page.tsx
import { notFound } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { getSession } from '@/lib/auth';
import type { Ticket, StrapiSingleResponse } from '@/types/strapi';
async function getTicket(documentId: string, jwt: string): Promise<Ticket | null> {
// 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.
const res = await fetch(
`${process.env.STRAPI_URL}/api/tickets/${documentId}?populate[replies]=*&fields[0]=message&fields[1]=createdAt`,
{ headers: { Authorization: `Bearer ${jwt}` }, cache: 'no-store' }
);
if (!res.ok) return null;
const { data }: StrapiSingleResponse<Ticket> = await res.json();
return data;
}
export default async function TicketDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const session = await getSession();
if (!session) throw new Error('Unauthorized');
const ticket = await getTicket(id, session.jwt);
if (!ticket) notFound();
async function addReply(formData: FormData) {
'use server';
const s = await getSession();
if (!s) throw new Error('Unauthorized');
const res = await fetch(`${process.env.STRAPI_URL}/api/replies`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${s.jwt}`,
},
body: JSON.stringify({
data: {
message: formData.get('message'),
author: { connect: [s.userId] },
ticket: { connect: [id] },
},
}),
});
if (!res.ok) throw new Error(`Failed to add reply: ${res.status}`);
revalidatePath(`/tickets/${id}`);
}
return (
<main className="mx-auto max-w-2xl px-4 py-12">
<h1 className="text-2xl font-bold">{ticket.subject}</h1>
<p className="mt-2 text-gray-600">{ticket.description}</p>
<h2 className="mt-8 text-lg font-semibold">Replies</h2>
<ul className="mt-4 space-y-3">
{ticket.replies?.map((reply) => (
<li key={reply.documentId} className="rounded border p-3">
{reply.message}
</li>
))}
</ul>
<form action={addReply} className="mt-6 space-y-3">
<textarea
name="message"
required
rows={3}
placeholder="Add a reply..."
className="w-full rounded border px-3 py-2"
/>
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white">
Send Reply
</button>
</form>
</main>
);
}The populate is explicit on replies, requesting only message and createdAt.
Putting It All Together
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.
How Strapi Powers This
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.
Next Steps
You have a working dual-surface portal. From here, the logical follow-on work:
- Deploy Strapi to Strapi Cloud (the backend needs a persistent server, so Vercel is not an option for it) and deploy the Next.js frontend to Vercel.
- Add full-text search to the knowledge base with Meilisearch for typo tolerance and ranking the basic
$containsifilter can't match. - Build a staff-facing ticket management dashboard with the
assignedAgentrelation and bulk status actions. - Add SLA tracking with custom fields and a scheduled cron task that flags tickets approaching their deadline.
- Read the Strapi 5 documentation and the Next.js documentation for deeper dives into Document Service, Draft & Publish, and the REST API.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.