Product teams burn hours triaging feedback scattered across email, support tickets, and Slack threads. A public roadmap board consolidates that into one place to submit ideas, vote on what matters, and watch features move from "under review" to "shipped." Commercial tools handle this well, but a self-hosted build gives you more control over where user data lives and avoids tracked-user pricing models.
This guide walks through building your own self-hosted alternative with Strapi 5 and Next.js 16. You get full data ownership, public pages at stable URLs, and a voting system you control end to end. Strapi 5 powers the backend with a custom voting API, status management, and user authentication. Next.js 16 renders the public roadmap with Incremental Static Regeneration (ISR) so pages stay fast and fresh.
In brief:
voteCount field in sync with Document Service middlewares, avoiding the lifecycle hook double-fire problemThe end product is a public, SEO-friendly product roadmap board. Visitors see three status columns (planned, in progress, and shipped) with feature requests sorted by vote count. Each feature has its own detail page with a description, a status badge, and a vote button. Logged-in users can submit new requests, which default to an "under review" status until staff move them through the pipeline.
Strapi 5 handles the data layer and business logic. A custom controller toggles votes on and off, querying for an existing vote by the user and feature request pair to enforce one vote per person. A Document Service middleware keeps each feature request's voteCount accurate whenever votes are created or deleted. The Users and Permissions plugin gates submissions so only authenticated users can post, while status transitions stay restricted to staff.
Next.js 16 renders the public-facing roadmap. ISR generates the roadmap pages statically and revalidates them periodically so vote counts stay current without sacrificing speed. Server Actions call the Strapi voting endpoint, and React 19's useOptimistic hook gives instant feedback on the vote button.
The public-page payoff is the part commercial tools cannot match. Because Next.js 16 renders each feature request as a statically generated page at its own URL, every idea your users submit gets a clean, shareable page. A request titled "Dark mode for the dashboard" lives at a stable path and doubles as a public commitment to your community. Self-hosting the whole stack means that traffic, and the data behind it, stays on infrastructure you control rather than a vendor's.
What you'll learn:
Before starting, make sure you have:
npx create-strapi@latest)Strapi 5 supports only Active LTS or Maintenance LTS Node.js versions. v24 satisfies both Strapi 5 and Next.js 16, which dropped Node.js 18 support.
Create the project with the official CLI:
1npx create-strapi@latest roadmap-backendThe CLI walks you through setup. Choose a custom installation when prompted so you can select PostgreSQL as your database. Provide your connection details (database name, host, port, username, password).
If you prefer to configure the database manually, your config/database.ts should look like this for PostgreSQL:
1// config/database.ts
2export default ({ env }) => ({
3 connection: {
4 client: 'postgres',
5 connection: {
6 host: env('DATABASE_HOST', '127.0.0.1'),
7 port: env.int('DATABASE_PORT', 5432),
8 database: env('DATABASE_NAME', 'strapi'),
9 user: env('DATABASE_USERNAME', 'strapi'),
10 password: env('DATABASE_PASSWORD', 'strapi'),
11 schema: env('DATABASE_SCHEMA', 'public'),
12 ssl: env.bool('DATABASE_SSL', false) && {
13 rejectUnauthorized: env.bool('DATABASE_SSL_SELF', false),
14 },
15 },
16 pool: {
17 min: env.int('DATABASE_POOL_MIN', 2),
18 max: env.int('DATABASE_POOL_MAX', 10),
19 },
20 debug: false,
21 },
22});One thing that trips people up: a PostgreSQL user created for Strapi needs schema permissions. Without them, you'll hit a 500 error loading the Admin Panel. Grant those permissions before starting Strapi.
Start the development server:
1cd roadmap-backend
2npm run developCreate your admin account in the Admin Panel when the browser opens.
You can build these through the Content-Type Builder in the Admin Panel, but defining the schemas directly gives you precise control. Strapi 5 stores Content-Type models at ./src/api/[api-name]/content-types/[content-type-name]/schema.json.
Start with the Category Collection Type. Create the file at the path below:
1// src/api/category/content-types/category/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "categories",
5 "info": {
6 "singularName": "category",
7 "pluralName": "categories",
8 "displayName": "Category"
9 },
10 "options": {
11 "draftAndPublish": false
12 },
13 "attributes": {
14 "name": {
15 "type": "string",
16 "required": true
17 },
18 "slug": {
19 "type": "uid",
20 "targetField": "name"
21 },
22 "colorCode": {
23 "type": "string"
24 },
25 "sortOrder": {
26 "type": "integer",
27 "default": 0
28 },
29 "featureRequests": {
30 "type": "relation",
31 "relation": "oneToMany",
32 "target": "api::feature-request.feature-request",
33 "mappedBy": "category"
34 }
35 }
36}Note draftAndPublish: false. For a public roadmap, you want every entry visible without managing a separate published state. With Draft and Publish disabled, publishedAt is always set to a date, so entries appear in public API responses by default.
Next, the FeatureRequest Collection Type. This carries the status enum, the relations, and the derived voteCount:
1// src/api/feature-request/content-types/feature-request/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "feature_requests",
5 "info": {
6 "singularName": "feature-request",
7 "pluralName": "feature-requests",
8 "displayName": "Feature Request"
9 },
10 "options": {
11 "draftAndPublish": false
12 },
13 "attributes": {
14 "title": {
15 "type": "string",
16 "required": true
17 },
18 "description": {
19 "type": "text"
20 },
21 "status": {
22 "type": "enumeration",
23 "enum": ["under_review", "planned", "in_progress", "shipped", "declined"],
24 "default": "under_review",
25 "required": true
26 },
27 "voteCount": {
28 "type": "integer",
29 "default": 0
30 },
31 "category": {
32 "type": "relation",
33 "relation": "manyToOne",
34 "target": "api::category.category",
35 "inversedBy": "featureRequests"
36 },
37 "author": {
38 "type": "relation",
39 "relation": "manyToOne",
40 "target": "plugin::users-permissions.user"
41 },
42 "votes": {
43 "type": "relation",
44 "relation": "oneToMany",
45 "target": "api::vote.vote",
46 "mappedBy": "featureRequest"
47 }
48 }
49}The Vote Collection Type links a user to a feature request. This is the pair you query against to enforce one vote per user:
1// src/api/vote/content-types/vote/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "votes",
5 "info": {
6 "singularName": "vote",
7 "pluralName": "votes",
8 "displayName": "Vote"
9 },
10 "options": {
11 "draftAndPublish": false
12 },
13 "attributes": {
14 "user": {
15 "type": "relation",
16 "relation": "manyToOne",
17 "target": "plugin::users-permissions.user"
18 },
19 "featureRequest": {
20 "type": "relation",
21 "relation": "manyToOne",
22 "target": "api::feature-request.feature-request",
23 "inversedBy": "votes"
24 }
25 }
26}Restart Strapi so it picks up the new schemas. The Admin Panel will show all three Collection Types.
The core voting logic lives in a custom route and controller. Strapi 5 recommends the Document Service API for backend database operations. The Entity Service API from Strapi v4 is deprecated, so every query here goes through strapi.documents().
First, the route. Strapi 5 custom routes use a fully qualified handler string in the format api::<api-name>.<controllerName>.<actionName>. Create the route file:
1// src/api/vote/routes/vote.ts
2export default {
3 routes: [
4 {
5 method: 'POST',
6 path: '/votes/toggle',
7 handler: 'api::vote.vote.toggle',
8 config: {
9 policies: ['plugin::users-permissions.isAuthenticated'],
10 },
11 },
12 ],
13};The isAuthenticated policy from the Users and Permissions plugin rejects any request without a valid token, so only logged-in users can vote.
The toggle logic queries for an existing vote by the user and feature request pair. If one exists, it deletes it (un-vote). If not, it creates one. This single endpoint handles both directions:
1// src/api/vote/controllers/vote.ts
2import type { Core } from '@strapi/strapi';
3
4export default {
5 async toggle(ctx) {
6 const { featureRequestDocumentId } = ctx.request.body;
7 const userId = ctx.state.user.id;
8
9 if (!featureRequestDocumentId) {
10 return ctx.badRequest('featureRequestDocumentId is required');
11 }
12
13 const existing = await strapi.documents('api::vote.vote').findMany({
14 filters: {
15 user: { id: { $eq: userId } },
16 featureRequest: { documentId: { $eq: featureRequestDocumentId } },
17 },
18 });
19
20 if (existing.length > 0) {
21 await strapi.documents('api::vote.vote').delete({
22 documentId: existing[0].documentId,
23 });
24 ctx.body = { voted: false };
25 } else {
26 const vote = await strapi.documents('api::vote.vote').create({
27 data: {
28 user: userId,
29 featureRequest: featureRequestDocumentId,
30 },
31 });
32 ctx.body = { voted: true, documentId: vote.documentId };
33 }
34 },
35};The compound filter matches both the numeric user id and the feature request documentId. This is how one vote per user gets enforced. There's no way to create a second vote for the same pair because the toggle finds and removes the existing one first.
The Document Service does not sanitize output, unlike the core controllers. If you return full document objects to clients, run them through strapi.contentAPI.sanitize.output() first. The toggle response here returns only a boolean and a documentId, so there's nothing sensitive to leak.
Storing voteCount as a field on FeatureRequest keeps sorting fast, but it means you have to keep that number accurate whenever votes change. The instinct is to reach for a lifecycle hook like afterCreate. In Strapi 5, that's a trap.
Creating a published document fires beforeCreate and afterCreate twice because published versions are immutable while a draft is kept for edits. The actions a single Document Service call triggers are more complex than they were in v4. Trying to filter what's happening at the database level becomes a mess. This double-fire is confirmed in the issue tracker.
The recommended approach is a Document Service middleware. It fires once per logical Document Service call, at the right level of abstraction. Register it in the register lifecycle of your application entry point:
1// src/index.ts
2import type { Core } from '@strapi/strapi';
3
4export default {
5 register({ strapi }: { strapi: Core.Strapi }) {
6 strapi.documents.use(async (context, next) => {
7 if (context.uid !== 'api::vote.vote') {
8 return next();
9 }
10
11 let featureRequestDocumentId: string | undefined;
12
13 if (context.action === 'create') {
14 featureRequestDocumentId = context.params.data?.featureRequest;
15 } else if (context.action === 'delete') {
16 const vote = await strapi.documents('api::vote.vote').findOne({
17 documentId: context.params.documentId,
18 populate: { featureRequest: { fields: ['documentId'] } },
19 });
20 featureRequestDocumentId = vote?.featureRequest?.documentId;
21 }
22
23 const result = await next();
24
25 if (['create', 'delete'].includes(context.action) && featureRequestDocumentId) {
26 const votes = await strapi.documents('api::vote.vote').findMany({
27 filters: {
28 featureRequest: { documentId: { $eq: featureRequestDocumentId } },
29 },
30 });
31
32 await strapi.documents('api::feature-request.feature-request').update({
33 documentId: featureRequestDocumentId,
34 data: { voteCount: votes.length },
35 });
36 }
37
38 return result;
39 });
40 },
41
42 bootstrap() {},
43};For a delete action, the relation is gone after next() runs, so you capture the feature request documentId first. For a create, the data is available on context.params.data. After the operation completes, the middleware recounts all votes for that feature request and writes the total back. Recounting rather than incrementing keeps the number correct even if a vote slipped through some other path.
One note: bulk action lifecycles like deleteMany won't trigger this middleware unless you call the Document Service delete method per document. Since the voting controller deletes one vote at a time, you're covered.
The roadmap needs two audiences: anonymous visitors who read and authenticated users who submit and vote. Strapi 5's Users and Permissions plugin ships with two default roles, Public and Authenticated, which map onto this setup.
In the Admin Panel, go to Settings → Users & Permissions plugin → Roles.
For the Public role, enable find and findOne on Feature Request and Category so anyone can read the roadmap. Leave create, update, and delete disabled.
For the Authenticated role, enable find and findOne on Feature Request and Category, plus create on Feature Request so logged-in users can submit. Keep update and delete disabled. Enable the custom vote toggle endpoint by ticking the toggle action under the Vote permissions.
New users that register through /api/auth/local/register get the Authenticated role by default. To make submitted requests default to "under review" regardless of what a client sends, set the default value in the schema (already done in Step 2) and enforce it with a route middleware that also blocks non-staff from setting status.
Create the middleware:
1// src/api/feature-request/middlewares/restrict-status.ts
2import type { Core } from '@strapi/strapi';
3
4export default (config: unknown, { strapi }: { strapi: Core.Strapi }) => {
5 return async (ctx, next) => {
6 const user = ctx.state.user;
7 const isStaff = user?.role?.type === 'staff';
8
9 if (ctx.request.body?.data && !isStaff) {
10 ctx.request.body.data.status = 'under_review';
11 ctx.request.body.data.author = user.id;
12 }
13
14 await next();
15 };
16};A route middleware can mutate the request, which a policy cannot. This one forces the status to "under review" for non-staff and stamps the author. Wire it into the FeatureRequest routes by overriding the core router:
1// src/api/feature-request/routes/feature-request.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreRouter('api::feature-request.feature-request', {
5 config: {
6 find: { auth: false },
7 findOne: { auth: false },
8 create: {
9 policies: ['plugin::users-permissions.isAuthenticated'],
10 middlewares: ['api::feature-request.restrict-status'],
11 },
12 },
13});The find and findOne routes set auth: false so the public roadmap reads work without a token. The create route requires authentication and runs the status middleware. To create a staff role for end users, add a new role under Users & Permissions → Roles, set its name/description and permissions, then assign it to the relevant user accounts. If you want staff to set status freely, update your middleware to skip restrictions for the staff role.
Create the frontend with the App Router and TypeScript:
1npx create-next-app@latest roadmap-frontend --typescript --eslint --app
2cd roadmap-frontendAdd Tailwind CSS v4 for styling:
1npm install tailwindcss @tailwindcss/postcss postcssConfigure PostCSS at the project root:
1// postcss.config.mjs
2export default {
3 plugins: {
4 "@tailwindcss/postcss": {},
5 },
6};Tailwind v4 uses a single import instead of v3's three directives, and it needs no config file. Add the import to your global stylesheet:
1/* app/globals.css */
2@import "tailwindcss";Set your environment variables. Next.js 16 removed serverRuntimeConfig, so read process.env in Server Components:
1# .env.local
2STRAPI_URL=http://localhost:1337
3NEXT_PUBLIC_STRAPI_URL=http://localhost:1337Define the TypeScript interfaces that match Strapi 5's flat response format. Notice there's no data.attributes wrapper, and documentId is the primary reference:
1// types/index.ts
2export type FeatureStatus =
3 | 'under_review'
4 | 'planned'
5 | 'in_progress'
6 | 'shipped'
7 | 'declined';
8
9export interface Category {
10 documentId: string;
11 name: string;
12 slug: string;
13 colorCode?: string;
14}
15
16export interface FeatureRequest {
17 documentId: string;
18 title: string;
19 description?: string;
20 status: FeatureStatus;
21 voteCount: number;
22 createdAt: string;
23 category?: Category;
24}
25
26export interface StrapiListResponse<T> {
27 data: T[];
28 meta: {
29 pagination?: {
30 page: number;
31 pageSize: number;
32 pageCount: number;
33 total: number;
34 };
35 };
36}
37
38export interface StrapiSingleResponse<T> {
39 data: T;
40 meta: Record<string, unknown>;
41}The roadmap shows three columns: planned, in progress, and shipped. Each column fetches feature requests filtered by status, sorted by vote count. ISR keeps the pages fast while revalidating periodically.
Start with a fetch helper. The Strapi 5 REST API filters use LHS bracket syntax, and sorting supports a colon for direction:
1// lib/strapi.ts
2import type { FeatureRequest, StrapiListResponse, FeatureStatus } from '@/types';
3
4const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
5
6export async function getFeaturesByStatus(
7 status: FeatureStatus
8): Promise<FeatureRequest[]> {
9 const params = new URLSearchParams();
10 params.set('filters[status][$eq]', status);
11 params.set('sort[0]', 'voteCount:desc');
12 params.set('fields[0]', 'title');
13 params.set('fields[1]', 'status');
14 params.set('fields[2]', 'voteCount');
15 params.set('fields[3]', 'description');
16 params.set('populate[category][fields][0]', 'name');
17 params.set('populate[category][fields][1]', 'colorCode');
18
19 const res = await fetch(`${STRAPI_URL}/api/feature-requests?${params}`, {
20 next: { revalidate: 60, tags: ['feature-requests'] },
21 });
22
23 if (!res.ok) {
24 throw new Error(`Failed to fetch features: ${res.status}`);
25 }
26
27 const json: StrapiListResponse<FeatureRequest> = await res.json();
28 return json.data;
29}The fetch uses explicit field selection and targeted populate rather than populate=*, which is the production best practice for performance. The next: { revalidate: 60 } option enables ISR with a 60-second window, and the tags array lets you bust the cache on demand later.
The route segment revalidate export sets the ISR window for the whole route:
1// app/page.tsx
2import Link from 'next/link';
3import { getFeaturesByStatus } from '@/lib/strapi';
4import type { FeatureRequest, FeatureStatus } from '@/types';
5
6export const revalidate = 60;
7
8const COLUMNS: { status: FeatureStatus; label: string }[] = [
9 { status: 'planned', label: 'Planned' },
10 { status: 'in_progress', label: 'In Progress' },
11 { status: 'shipped', label: 'Shipped' },
12];
13
14function FeatureCard({ feature }: { feature: FeatureRequest }) {
15 return (
16 <Link
17 href={`/features/${feature.documentId}`}
18 className="block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md"
19 >
20 <div className="flex items-start justify-between gap-3">
21 <h3 className="font-medium text-gray-900">{feature.title}</h3>
22 <span className="shrink-0 rounded bg-gray-100 px-2 py-1 text-sm font-semibold">
23 ▲ {feature.voteCount}
24 </span>
25 </div>
26 {feature.category && (
27 <span
28 className="mt-2 inline-block rounded-full px-2 py-0.5 text-xs text-white"
29 style={{ backgroundColor: feature.category.colorCode ?? '#6b7280' }}
30 >
31 {feature.category.name}
32 </span>
33 )}
34 </Link>
35 );
36}
37
38export default async function RoadmapPage() {
39 const columns = await Promise.all(
40 COLUMNS.map(async (col) => ({
41 ...col,
42 features: await getFeaturesByStatus(col.status),
43 }))
44 );
45
46 return (
47 <main className="mx-auto max-w-6xl px-4 py-10">
48 <h1 className="mb-2 text-3xl font-bold">Product Roadmap</h1>
49 <p className="mb-8 text-gray-600">
50 Vote on features and submit your own ideas.
51 </p>
52 <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
53 {columns.map((col) => (
54 <section key={col.status}>
55 <h2 className="mb-4 text-lg font-semibold">{col.label}</h2>
56 <div className="space-y-3">
57 {col.features.map((feature) => (
58 <FeatureCard key={feature.documentId} feature={feature} />
59 ))}
60 {col.features.length === 0 && (
61 <p className="text-sm text-gray-400">Nothing here yet.</p>
62 )}
63 </div>
64 </section>
65 ))}
66 </div>
67 </main>
68 );
69}During next build, Next.js generates this page statically. Requests return the cached version instantly. After 60 seconds, the next request gets the stale page while a fresh version regenerates in the background. ISR runs only on the Node.js runtime, which is the default.
Each feature gets its own page at /features/[documentId]. These pages are the public-page payoff: every feature request becomes its own URL. The page fetches a single feature and renders a vote button backed by a Server Action.
First, the single-fetch helper:
1// lib/strapi.ts (add to existing file)
2import type { StrapiSingleResponse } from '@/types';
3
4export async function getFeatureById(
5 documentId: string
6): Promise<FeatureRequest | null> {
7 const params = new URLSearchParams();
8 params.set('populate[category][fields][0]', 'name');
9 params.set('populate[category][fields][1]', 'colorCode');
10
11 const res = await fetch(
12 `${STRAPI_URL}/api/feature-requests/${documentId}?${params}`,
13 { next: { revalidate: 60, tags: ['feature-requests'] } }
14 );
15
16 if (res.status === 404) return null;
17 if (!res.ok) throw new Error(`Failed to fetch feature: ${res.status}`);
18
19 const json: StrapiSingleResponse<FeatureRequest> = await res.json();
20 return json.data;
21}The Server Action calls the custom Strapi vote endpoint. Next.js 16 Server Actions use POST requests, so verify authentication inside each one. The JWT comes from a cookie set during login:
1// app/actions/votes.ts
2'use server';
3
4import { revalidateTag } from 'next/cache';
5import { cookies } from 'next/headers';
6
7export async function toggleVote(featureRequestDocumentId: string) {
8 const cookieStore = await cookies();
9 const token = cookieStore.get('jwt')?.value;
10
11 if (!token) {
12 throw new Error('You must be logged in to vote');
13 }
14
15 const res = await fetch(`${process.env.STRAPI_URL}/api/votes/toggle`, {
16 method: 'POST',
17 headers: {
18 'Content-Type': 'application/json',
19 Authorization: `Bearer ${token}`,
20 },
21 body: JSON.stringify({ featureRequestDocumentId }),
22 });
23
24 if (!res.ok) {
25 throw new Error('Failed to toggle vote');
26 }
27
28 revalidateTag('feature-requests');
29 return res.json();
30}After the mutation, revalidateTag('feature-requests') invalidates every fetch tagged with feature-requests. The combination of a 60-second ISR window and on-demand revalidation provides a freshness safety net and eventual background updates after a vote, but does not guarantee instant UI updates.
The vote button is a Client Component using React 19's useOptimistic hook for instant feedback. Use relative updates rather than absolute ones so the count handles concurrent changes correctly:
1// app/features/[documentId]/vote-button.tsx
2'use client';
3
4import { useOptimistic } from 'react';
5import { toggleVote } from '@/app/actions/votes';
6
7export function VoteButton({
8 featureRequestDocumentId,
9 initialCount,
10 userHasVoted,
11}: {
12 featureRequestDocumentId: string;
13 initialCount: number;
14 userHasVoted: boolean;
15}) {
16 const [optimisticCount, adjustCount] = useOptimistic(
17 initialCount,
18 (current: number, delta: number) => current + delta
19 );
20 const [optimisticVoted, setVoted] = useOptimistic(userHasVoted);
21
22 return (
23 <form
24 action={async () => {
25 const delta = optimisticVoted ? -1 : 1;
26 adjustCount(delta);
27 setVoted(!optimisticVoted);
28 await toggleVote(featureRequestDocumentId);
29 }}
30 >
31 <button
32 type="submit"
33 className="rounded-lg border-2 border-blue-600 px-4 py-2 font-semibold text-blue-600 hover:bg-blue-50"
34 >
35 {optimisticVoted ? '▲ Voted' : '▲ Vote'} ({optimisticCount})
36 </button>
37 </form>
38 );
39}The detail page renders a status badge and description:
1// app/features/[documentId]/page.tsx
2import { notFound } from 'next/navigation';
3import Link from 'next/link';
4import { getFeatureById } from '@/lib/strapi';
5import { VoteButton } from './vote-button';
6import type { FeatureStatus } from '@/types';
7
8export const revalidate = 60;
9
10const STATUS_LABELS: Record<FeatureStatus, string> = {
11 under_review: 'Under Review',
12 planned: 'Planned',
13 in_progress: 'In Progress',
14 shipped: 'Shipped',
15 declined: 'Declined',
16};
17
18export default async function FeaturePage({
19 params,
20}: {
21 params: Promise<{ documentId: string }>;
22}) {
23 const { documentId } = await params;
24 const feature = await getFeatureById(documentId);
25
26 if (!feature) {
27 notFound();
28 }
29
30 return (
31 <main className="mx-auto max-w-3xl px-4 py-10">
32 <Link href="/" className="text-sm text-blue-600 hover:underline">
33 ← Back to roadmap
34 </Link>
35 <div className="mt-4 flex items-start justify-between gap-6">
36 <div>
37 <h1 className="text-2xl font-bold">{feature.title}</h1>
38 <span className="mt-2 inline-block rounded bg-gray-100 px-3 py-1 text-sm font-medium">
39 {STATUS_LABELS[feature.status]}
40 </span>
41 </div>
42 <VoteButton
43 featureRequestDocumentId={feature.documentId}
44 initialCount={feature.voteCount}
45 userHasVoted={false}
46 />
47 </div>
48 {feature.description && (
49 <p className="mt-6 whitespace-pre-line text-gray-700">
50 {feature.description}
51 </p>
52 )}
53 </main>
54 );
55}In Next.js 16, params is a Promise, so you await it before reading values. The userHasVoted prop is hardcoded to false here for brevity; in production you'd check whether the current user has a vote on this feature using a populated, filtered query.
A list page with search and sort controls helps users find existing requests before submitting duplicates. Server-side sorting and filtering use Strapi's REST parameters; client-side search handles instant title matching.
The fetch helper accepts a sort option:
1// lib/strapi.ts (add to existing file)
2type SortOption = 'votes' | 'newest' | 'status';
3
4const SORT_MAP: Record<SortOption, string> = {
5 votes: 'voteCount:desc',
6 newest: 'createdAt:desc',
7 status: 'status:asc',
8};
9
10export async function getAllFeatures(
11 sort: SortOption = 'votes'
12): Promise<FeatureRequest[]> {
13 const params = new URLSearchParams();
14 params.set('sort[0]', SORT_MAP[sort]);
15 params.set('fields[0]', 'title');
16 params.set('fields[1]', 'status');
17 params.set('fields[2]', 'voteCount');
18 params.set('fields[3]', 'createdAt');
19 params.set('pagination[pageSize]', '100');
20
21 const res = await fetch(`${STRAPI_URL}/api/feature-requests?${params}`, {
22 next: { revalidate: 60, tags: ['feature-requests'] },
23 });
24
25 if (!res.ok) throw new Error(`Failed to fetch features: ${res.status}`);
26
27 const json: StrapiListResponse<FeatureRequest> = await res.json();
28 return json.data;
29}A Client Component handles search input and the sort selector. It filters the title client-side for instant results:
1// app/features/feature-list.tsx
2'use client';
3
4import { useState, useMemo } from 'react';
5import Link from 'next/link';
6import type { FeatureRequest } from '@/types';
7
8export function FeatureList({ features }: { features: FeatureRequest[] }) {
9 const [query, setQuery] = useState('');
10
11 const filtered = useMemo(() => {
12 const q = query.trim().toLowerCase();
13 if (!q) return features;
14 return features.filter((f) => f.title.toLowerCase().includes(q));
15 }, [query, features]);
16
17 return (
18 <div>
19 <input
20 type="search"
21 placeholder="Search feature requests..."
22 value={query}
23 onChange={(e) => setQuery(e.target.value)}
24 className="mb-6 w-full rounded-lg border border-gray-300 px-4 py-2"
25 />
26 <ul className="space-y-3">
27 {filtered.map((feature) => (
28 <li key={feature.documentId}>
29 <Link
30 href={`/features/${feature.documentId}`}
31 className="flex items-center justify-between rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
32 >
33 <span className="font-medium">{feature.title}</span>
34 <span className="text-sm font-semibold text-gray-500">
35 ▲ {feature.voteCount}
36 </span>
37 </Link>
38 </li>
39 ))}
40 {filtered.length === 0 && (
41 <li className="text-gray-400">No matching requests.</li>
42 )}
43 </ul>
44 </div>
45 );
46}The list page reads the sort from the URL search params and passes data into the client component:
1// app/features/page.tsx
2import { getAllFeatures } from '@/lib/strapi';
3import { FeatureList } from './feature-list';
4
5export const revalidate = 60;
6
7export default async function FeaturesPage({
8 searchParams,
9}: {
10 searchParams: Promise<{ sort?: string }>;
11}) {
12 const { sort } = await searchParams;
13 const validSort = sort === 'newest' || sort === 'status' ? sort : 'votes';
14 const features = await getAllFeatures(validSort);
15
16 return (
17 <main className="mx-auto max-w-3xl px-4 py-10">
18 <h1 className="mb-6 text-2xl font-bold">All Feature Requests</h1>
19 <nav className="mb-6 flex gap-4 text-sm">
20 <a href="?sort=votes" className="text-blue-600 hover:underline">
21 Most voted
22 </a>
23 <a href="?sort=newest" className="text-blue-600 hover:underline">
24 Newest
25 </a>
26 <a href="?sort=status" className="text-blue-600 hover:underline">
27 By status
28 </a>
29 </nav>
30 <FeatureList features={features} />
31 </main>
32 );
33}Sorting happens server-side through Strapi's sort parameter so the cached page reflects the chosen order. Search runs client-side for instant filtering without a round trip.
This split is a deliberate tradeoff. Server-side sorting through Strapi's sort parameter runs once at build or revalidation time, so the cached page arrives in the right order and search engines see a consistent ranking.
Pushing search to the client avoids a network round trip on every keystroke, which feels instant for the few hundred requests a typical roadmap holds. If your dataset grows into the thousands, move search server-side with a filters[title][$containsi] query and pagination so you are not shipping the entire list to the browser.
With both servers running (npm run develop for Strapi on port 1337, npm run dev for Next.js on port 3000), walk through the full flow.
Open http://localhost:3000. The roadmap renders three columns. Seed a few feature requests through the Strapi Admin Panel with different statuses and categories so the columns populate. Each card links to its detail page.
Register a user by sending a POST request to /api/auth/local/register with a username, email, and password. The response includes a JWT, which your login flow stores in the jwt cookie. New registrations get the Authenticated role automatically.
Submit a feature request as that authenticated user. POST to /api/feature-requests with a title and description. The restrict-status middleware forces the status to under_review regardless of the value the client sends. For teams that need a formal triage process, Review Workflows let you assign items to review stages in the Admin Panel.
Upvote an existing feature from its detail page. The optimistic UI bumps the count instantly while the Server Action handles the vote-toggle mutation. The Document Service middleware recounts votes and writes the new voteCount. Click again to un-vote; the toggle finds your existing vote and deletes it, and the count drops.
To confirm the Document Service middleware is doing its job, open the feature request in the Admin Panel after each vote and check the voteCount field against the number of related Vote entries. The middleware recounts from scratch on every create and delete, so the stored number should always match the actual vote total. If the two ever drift, the recount on the next vote corrects it, which is why recounting beats incrementing a counter that can fall out of sync.
Try voting twice in a row. The compound filter on user and feature request guarantees one vote per user: the second click removes the first vote rather than adding a duplicate. Switch to a staff account and move a request from "under review" to "planned" in the Admin Panel, then watch it appear in the Planned column after the ISR window passes or after the next revalidateTag call.
Every piece of this build runs through Strapi 5's backend customization layer. The Document Service API gives you programmatic control over votes and feature requests without writing raw SQL. Document Service middlewares keep derived data accurate at the application level, sidestepping the lifecycle hook pitfalls that caught teams in earlier versions.
The Users and Permissions plugin handles authentication and role-based access with configuration, not custom auth code. And because Strapi exposes a REST API out of the box, the Next.js frontend consumes data through standard fetch calls with ISR caching. You get a production-ready backend for a public roadmap with full data ownership, custom roles, and no vendor lock-in on pricing or infrastructure.
You have a working public roadmap with voting, submissions, and ISR. From here, consider:
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.