✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem24 min read

How to Build a Help Desk and Customer Support Portal with Next.js 16 and Strapi 5

June 17, 2026Updated on June 23, 2026
Build a Help Desk Portal

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.

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-backend

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

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

Generate 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.ts

The 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 &ldquo;{term}&rdquo;</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 $containsi filter can't match.
  • Build a staff-facing ticket management dashboard with the assignedAgent relation 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.

Paul BratslavskyDeveloper Advocate

Related Posts

Building a Construction Project Management Portal with TanStack
Ecosystem·22 min read

How to Build a Construction Project Management Portal with TanStack Start and Strapi 5

Construction projects fail in the gaps between roles. A project manager updates a schedule that a contractor never...

·June 17, 2026
Epic Next.js and Strapi Tutorial
37 min read

Epic Next.js 15 Tutorial Part 3: Finishup up the homepage Features Section, TopNavigation and Footer

In part 3 of our series, let's finish building out our home page. We will finish up our Hero Section, then move to our...

·August 18, 2025
How to build a custom widget in Strapi
Intermediate·25 min read

How To Build A Truck Tracker Custom Field and Map Widget for Strapi Admin

In this blog tutorial, we’ll walk through how to customize Strapi Admin panel by building a custom Truck Tracker plugin...

·June 17, 2025