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:
- Model feature requests, votes, and categories as Strapi 5 Collection Types with explicit relations
- Build a custom voting API that enforces one vote per user using Document Service methods
- Keep a derived
voteCountfield in sync with Document Service middlewares, avoiding the lifecycle hook double-fire problem - Render the public roadmap with Next.js 16 ISR and optimistic UI updates for voting
What We're Building
The 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:
- Modeling feature requests, votes, and categories with Strapi 5 Content Types
- Building a custom voting API with one-vote-per-user enforcement
- Using Document Service middlewares to keep vote counts in sync
- Enabling public submissions through the Users and Permissions plugin
- Rendering the roadmap with Next.js 16 ISR, search, filtering, and sorting
Prerequisites
Before starting, make sure you have:
- Node.js v24 LTS (Long Term Support; Active LTS recommended; v22 Maintenance LTS also works; v18 is End of Life and unsupported by both frameworks)
- Strapi 5.x (this tutorial uses the latest stable release; verify with
npx create-strapi@latest) - Next.js 16.2.x (App Router, Server Components, ISR)
- PostgreSQL 16.x (within Strapi 5's supported range of 14.0 minimum)
- Basic familiarity with TypeScript, React, and REST APIs
- A code editor and terminal
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.
Setting Up the Strapi Backend
Step 1 — Install Strapi 5
Create the project with the official CLI:
npx 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:
// config/database.ts
export default ({ env }) => ({
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', '127.0.0.1'),
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),
},
debug: false,
},
});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:
cd roadmap-backend
npm run developCreate your admin account in the Admin Panel when the browser opens.
Step 2 — Define the FeatureRequest, Vote, and Category Content Types
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:
// src/api/category/content-types/category/schema.json
{
"kind": "collectionType",
"collectionName": "categories",
"info": {
"singularName": "category",
"pluralName": "categories",
"displayName": "Category"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"slug": {
"type": "uid",
"targetField": "name"
},
"colorCode": {
"type": "string"
},
"sortOrder": {
"type": "integer",
"default": 0
},
"featureRequests": {
"type": "relation",
"relation": "oneToMany",
"target": "api::feature-request.feature-request",
"mappedBy": "category"
}
}
}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:
// src/api/feature-request/content-types/feature-request/schema.json
{
"kind": "collectionType",
"collectionName": "feature_requests",
"info": {
"singularName": "feature-request",
"pluralName": "feature-requests",
"displayName": "Feature Request"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"title": {
"type": "string",
"required": true
},
"description": {
"type": "text"
},
"status": {
"type": "enumeration",
"enum": ["under_review", "planned", "in_progress", "shipped", "declined"],
"default": "under_review",
"required": true
},
"voteCount": {
"type": "integer",
"default": 0
},
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category",
"inversedBy": "featureRequests"
},
"author": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"votes": {
"type": "relation",
"relation": "oneToMany",
"target": "api::vote.vote",
"mappedBy": "featureRequest"
}
}
}The Vote Collection Type links a user to a feature request. This is the pair you query against to enforce one vote per user:
// src/api/vote/content-types/vote/schema.json
{
"kind": "collectionType",
"collectionName": "votes",
"info": {
"singularName": "vote",
"pluralName": "votes",
"displayName": "Vote"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"user": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"featureRequest": {
"type": "relation",
"relation": "manyToOne",
"target": "api::feature-request.feature-request",
"inversedBy": "votes"
}
}
}Restart Strapi so it picks up the new schemas. The Admin Panel will show all three Collection Types.
Step 3 — Build the Custom Vote Routes and Controllers
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:
// src/api/vote/routes/vote.ts
export default {
routes: [
{
method: 'POST',
path: '/votes/toggle',
handler: 'api::vote.vote.toggle',
config: {
policies: ['plugin::users-permissions.isAuthenticated'],
},
},
],
};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:
// src/api/vote/controllers/vote.ts
import type { Core } from '@strapi/strapi';
export default {
async toggle(ctx) {
const { featureRequestDocumentId } = ctx.request.body;
const userId = ctx.state.user.id;
if (!featureRequestDocumentId) {
return ctx.badRequest('featureRequestDocumentId is required');
}
const existing = await strapi.documents('api::vote.vote').findMany({
filters: {
user: { id: { $eq: userId } },
featureRequest: { documentId: { $eq: featureRequestDocumentId } },
},
});
if (existing.length > 0) {
await strapi.documents('api::vote.vote').delete({
documentId: existing[0].documentId,
});
ctx.body = { voted: false };
} else {
const vote = await strapi.documents('api::vote.vote').create({
data: {
user: userId,
featureRequest: featureRequestDocumentId,
},
});
ctx.body = { voted: true, documentId: vote.documentId };
}
},
};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.
Step 4 — Enforce Vote Counts with Document Service Middlewares
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:
// src/index.ts
import type { Core } from '@strapi/strapi';
export default {
register({ strapi }: { strapi: Core.Strapi }) {
strapi.documents.use(async (context, next) => {
if (context.uid !== 'api::vote.vote') {
return next();
}
let featureRequestDocumentId: string | undefined;
if (context.action === 'create') {
featureRequestDocumentId = context.params.data?.featureRequest;
} else if (context.action === 'delete') {
const vote = await strapi.documents('api::vote.vote').findOne({
documentId: context.params.documentId,
populate: { featureRequest: { fields: ['documentId'] } },
});
featureRequestDocumentId = vote?.featureRequest?.documentId;
}
const result = await next();
if (['create', 'delete'].includes(context.action) && featureRequestDocumentId) {
const votes = await strapi.documents('api::vote.vote').findMany({
filters: {
featureRequest: { documentId: { $eq: featureRequestDocumentId } },
},
});
await strapi.documents('api::feature-request.feature-request').update({
documentId: featureRequestDocumentId,
data: { voteCount: votes.length },
});
}
return result;
});
},
bootstrap() {},
};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.
Step 5 — Configure Users and Permissions for Public Submissions
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:
// src/api/feature-request/middlewares/restrict-status.ts
import type { Core } from '@strapi/strapi';
export default (config: unknown, { strapi }: { strapi: Core.Strapi }) => {
return async (ctx, next) => {
const user = ctx.state.user;
const isStaff = user?.role?.type === 'staff';
if (ctx.request.body?.data && !isStaff) {
ctx.request.body.data.status = 'under_review';
ctx.request.body.data.author = user.id;
}
await next();
};
};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:
// src/api/feature-request/routes/feature-request.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::feature-request.feature-request', {
config: {
find: { auth: false },
findOne: { auth: false },
create: {
policies: ['plugin::users-permissions.isAuthenticated'],
middlewares: ['api::feature-request.restrict-status'],
},
},
});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.
Building the Next.js 16 Frontend
Step 1 — Set Up the Next.js 16 Project
Create the frontend with the App Router and TypeScript:
npx create-next-app@latest roadmap-frontend --typescript --eslint --app
cd roadmap-frontendAdd Tailwind CSS v4 for styling:
npm install tailwindcss @tailwindcss/postcss postcssConfigure PostCSS at the project root:
// postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};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:
/* app/globals.css */
@import "tailwindcss";Set your environment variables. Next.js 16 removed serverRuntimeConfig, so read process.env in Server Components:
# .env.local
STRAPI_URL=http://localhost:1337
NEXT_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:
// types/index.ts
export type FeatureStatus =
| 'under_review'
| 'planned'
| 'in_progress'
| 'shipped'
| 'declined';
export interface Category {
documentId: string;
name: string;
slug: string;
colorCode?: string;
}
export interface FeatureRequest {
documentId: string;
title: string;
description?: string;
status: FeatureStatus;
voteCount: number;
createdAt: string;
category?: Category;
}
export interface StrapiListResponse<T> {
data: T[];
meta: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface StrapiSingleResponse<T> {
data: T;
meta: Record<string, unknown>;
}Step 2 — Build the Roadmap Board Layout
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:
// lib/strapi.ts
import type { FeatureRequest, StrapiListResponse, FeatureStatus } from '@/types';
const STRAPI_URL = process.env.STRAPI_URL ?? 'http://localhost:1337';
export async function getFeaturesByStatus(
status: FeatureStatus
): Promise<FeatureRequest[]> {
const params = new URLSearchParams();
params.set('filters[status][$eq]', status);
params.set('sort[0]', 'voteCount:desc');
params.set('fields[0]', 'title');
params.set('fields[1]', 'status');
params.set('fields[2]', 'voteCount');
params.set('fields[3]', 'description');
params.set('populate[category][fields][0]', 'name');
params.set('populate[category][fields][1]', 'colorCode');
const res = await fetch(`${STRAPI_URL}/api/feature-requests?${params}`, {
next: { revalidate: 60, tags: ['feature-requests'] },
});
if (!res.ok) {
throw new Error(`Failed to fetch features: ${res.status}`);
}
const json: StrapiListResponse<FeatureRequest> = await res.json();
return json.data;
}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:
// app/page.tsx
import Link from 'next/link';
import { getFeaturesByStatus } from '@/lib/strapi';
import type { FeatureRequest, FeatureStatus } from '@/types';
export const revalidate = 60;
const COLUMNS: { status: FeatureStatus; label: string }[] = [
{ status: 'planned', label: 'Planned' },
{ status: 'in_progress', label: 'In Progress' },
{ status: 'shipped', label: 'Shipped' },
];
function FeatureCard({ feature }: { feature: FeatureRequest }) {
return (
<Link
href={`/features/${feature.documentId}`}
className="block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md"
>
<div className="flex items-start justify-between gap-3">
<h3 className="font-medium text-gray-900">{feature.title}</h3>
<span className="shrink-0 rounded bg-gray-100 px-2 py-1 text-sm font-semibold">
▲ {feature.voteCount}
</span>
</div>
{feature.category && (
<span
className="mt-2 inline-block rounded-full px-2 py-0.5 text-xs text-white"
style={{ backgroundColor: feature.category.colorCode ?? '#6b7280' }}
>
{feature.category.name}
</span>
)}
</Link>
);
}
export default async function RoadmapPage() {
const columns = await Promise.all(
COLUMNS.map(async (col) => ({
...col,
features: await getFeaturesByStatus(col.status),
}))
);
return (
<main className="mx-auto max-w-6xl px-4 py-10">
<h1 className="mb-2 text-3xl font-bold">Product Roadmap</h1>
<p className="mb-8 text-gray-600">
Vote on features and submit your own ideas.
</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{columns.map((col) => (
<section key={col.status}>
<h2 className="mb-4 text-lg font-semibold">{col.label}</h2>
<div className="space-y-3">
{col.features.map((feature) => (
<FeatureCard key={feature.documentId} feature={feature} />
))}
{col.features.length === 0 && (
<p className="text-sm text-gray-400">Nothing here yet.</p>
)}
</div>
</section>
))}
</div>
</main>
);
}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.
Step 3 — Create Feature Detail Pages with Voting
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:
// lib/strapi.ts (add to existing file)
import type { StrapiSingleResponse } from '@/types';
export async function getFeatureById(
documentId: string
): Promise<FeatureRequest | null> {
const params = new URLSearchParams();
params.set('populate[category][fields][0]', 'name');
params.set('populate[category][fields][1]', 'colorCode');
const res = await fetch(
`${STRAPI_URL}/api/feature-requests/${documentId}?${params}`,
{ next: { revalidate: 60, tags: ['feature-requests'] } }
);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to fetch feature: ${res.status}`);
const json: StrapiSingleResponse<FeatureRequest> = await res.json();
return json.data;
}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:
// app/actions/votes.ts
'use server';
import { revalidateTag } from 'next/cache';
import { cookies } from 'next/headers';
export async function toggleVote(featureRequestDocumentId: string) {
const cookieStore = await cookies();
const token = cookieStore.get('jwt')?.value;
if (!token) {
throw new Error('You must be logged in to vote');
}
const res = await fetch(`${process.env.STRAPI_URL}/api/votes/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ featureRequestDocumentId }),
});
if (!res.ok) {
throw new Error('Failed to toggle vote');
}
revalidateTag('feature-requests');
return res.json();
}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:
// app/features/[documentId]/vote-button.tsx
'use client';
import { useOptimistic } from 'react';
import { toggleVote } from '@/app/actions/votes';
export function VoteButton({
featureRequestDocumentId,
initialCount,
userHasVoted,
}: {
featureRequestDocumentId: string;
initialCount: number;
userHasVoted: boolean;
}) {
const [optimisticCount, adjustCount] = useOptimistic(
initialCount,
(current: number, delta: number) => current + delta
);
const [optimisticVoted, setVoted] = useOptimistic(userHasVoted);
return (
<form
action={async () => {
const delta = optimisticVoted ? -1 : 1;
adjustCount(delta);
setVoted(!optimisticVoted);
await toggleVote(featureRequestDocumentId);
}}
>
<button
type="submit"
className="rounded-lg border-2 border-blue-600 px-4 py-2 font-semibold text-blue-600 hover:bg-blue-50"
>
{optimisticVoted ? '▲ Voted' : '▲ Vote'} ({optimisticCount})
</button>
</form>
);
}The detail page renders a status badge and description:
// app/features/[documentId]/page.tsx
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getFeatureById } from '@/lib/strapi';
import { VoteButton } from './vote-button';
import type { FeatureStatus } from '@/types';
export const revalidate = 60;
const STATUS_LABELS: Record<FeatureStatus, string> = {
under_review: 'Under Review',
planned: 'Planned',
in_progress: 'In Progress',
shipped: 'Shipped',
declined: 'Declined',
};
export default async function FeaturePage({
params,
}: {
params: Promise<{ documentId: string }>;
}) {
const { documentId } = await params;
const feature = await getFeatureById(documentId);
if (!feature) {
notFound();
}
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<Link href="/" className="text-sm text-blue-600 hover:underline">
← Back to roadmap
</Link>
<div className="mt-4 flex items-start justify-between gap-6">
<div>
<h1 className="text-2xl font-bold">{feature.title}</h1>
<span className="mt-2 inline-block rounded bg-gray-100 px-3 py-1 text-sm font-medium">
{STATUS_LABELS[feature.status]}
</span>
</div>
<VoteButton
featureRequestDocumentId={feature.documentId}
initialCount={feature.voteCount}
userHasVoted={false}
/>
</div>
{feature.description && (
<p className="mt-6 whitespace-pre-line text-gray-700">
{feature.description}
</p>
)}
</main>
);
}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.
Step 4 — Add Search, Filtering, and Sorting
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:
// lib/strapi.ts (add to existing file)
type SortOption = 'votes' | 'newest' | 'status';
const SORT_MAP: Record<SortOption, string> = {
votes: 'voteCount:desc',
newest: 'createdAt:desc',
status: 'status:asc',
};
export async function getAllFeatures(
sort: SortOption = 'votes'
): Promise<FeatureRequest[]> {
const params = new URLSearchParams();
params.set('sort[0]', SORT_MAP[sort]);
params.set('fields[0]', 'title');
params.set('fields[1]', 'status');
params.set('fields[2]', 'voteCount');
params.set('fields[3]', 'createdAt');
params.set('pagination[pageSize]', '100');
const res = await fetch(`${STRAPI_URL}/api/feature-requests?${params}`, {
next: { revalidate: 60, tags: ['feature-requests'] },
});
if (!res.ok) throw new Error(`Failed to fetch features: ${res.status}`);
const json: StrapiListResponse<FeatureRequest> = await res.json();
return json.data;
}A Client Component handles search input and the sort selector. It filters the title client-side for instant results:
// app/features/feature-list.tsx
'use client';
import { useState, useMemo } from 'react';
import Link from 'next/link';
import type { FeatureRequest } from '@/types';
export function FeatureList({ features }: { features: FeatureRequest[] }) {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return features;
return features.filter((f) => f.title.toLowerCase().includes(q));
}, [query, features]);
return (
<div>
<input
type="search"
placeholder="Search feature requests..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="mb-6 w-full rounded-lg border border-gray-300 px-4 py-2"
/>
<ul className="space-y-3">
{filtered.map((feature) => (
<li key={feature.documentId}>
<Link
href={`/features/${feature.documentId}`}
className="flex items-center justify-between rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
>
<span className="font-medium">{feature.title}</span>
<span className="text-sm font-semibold text-gray-500">
▲ {feature.voteCount}
</span>
</Link>
</li>
))}
{filtered.length === 0 && (
<li className="text-gray-400">No matching requests.</li>
)}
</ul>
</div>
);
}The list page reads the sort from the URL search params and passes data into the client component:
// app/features/page.tsx
import { getAllFeatures } from '@/lib/strapi';
import { FeatureList } from './feature-list';
export const revalidate = 60;
export default async function FeaturesPage({
searchParams,
}: {
searchParams: Promise<{ sort?: string }>;
}) {
const { sort } = await searchParams;
const validSort = sort === 'newest' || sort === 'status' ? sort : 'votes';
const features = await getAllFeatures(validSort);
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="mb-6 text-2xl font-bold">All Feature Requests</h1>
<nav className="mb-6 flex gap-4 text-sm">
<a href="?sort=votes" className="text-blue-600 hover:underline">
Most voted
</a>
<a href="?sort=newest" className="text-blue-600 hover:underline">
Newest
</a>
<a href="?sort=status" className="text-blue-600 hover:underline">
By status
</a>
</nav>
<FeatureList features={features} />
</main>
);
}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.
Putting It All Together
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.
How Strapi Powers This
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.
Next Steps
You have a working public roadmap with voting, submissions, and ISR. From here, consider:
- Deploy the backend to Strapi Cloud and the frontend to Vercel for managed hosting with git-based deploys
- Add email notifications for status changes by wiring up Strapi webhooks that trigger on feature request updates
- Build an admin view for triaging submitted requests, including duplicate merging
- Add comment threads on feature requests with a new Comment Collection Type related to FeatureRequest
- Explore the Strapi 5 documentation and the Next.js docs for caching strategies and authentication patterns
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.