If you’ve ever worked with WordPress or any traditional CMS, you probably noticed that both the frontend and backend are tightly connected.
That’s great for simple setups — until you want full control over how your frontend looks and behaves. Then it becomes a pain because you have to work around WordPress themes, plugins, and templating systems.
That’s where a headless CMS comes in.
What is Headless CMS
A management system for content without a frontend meaning you only get an admin panel or backend and then you’ll decide whatever frontend technology you’ll like to use in displaying your content.
Strapi is a CMS that you can use for your backend content management because when you use strapi, you get an API that you can use on your frontend built with any frontend technology (React, Vanilla JS, Swelt, Vue).
Example:
/blogs → Fetch all blog posts
/blogs/:id → Fetch a single blog post by ID
You’re going to use the fetch API to make requests for the data in the endpoints above, Strapi works really well with GraphQL.
While Strapi works beautifully with GraphQL, in this guide, we will use the built-in REST API and Next.js Server Actions to fetch our data efficiently.
Prerequisites
In order to build this API, you’re going to need the following:
- Knowledge of How GraphQL works.
- Nodejs installed on your system.
- An understanding of React and React Hooks.
Part A: Creating a New Project Using Strapi
Step 1
First on all, Create a game-review folder that will contain both your backend and frontend folders.
mkdir game-review
Step 2
Navigate into your game-review folder, then create a backend folder:
cd game-review
mkdir backend
Step 3
Run the following command into your terminal:
npx create-strapi@latest .
Step 4
Instead of manually configuring your environment, follow the official Strapi Quick Start Guide from no 2 under Step 1: Run the installation script and create a Strapi Cloud account to Step 2: Register the first local administrator user. This ensures you are using the most stable version and the latest features (like the automatic Strapi Cloud 30-day trial).
Step 5
- Visit: Strapi Quick Start Guide
- Action: Follow and run the commands in Part A: Create a new project with Strapi as directed*.*
Note:
To use GraphQL with Strapi, you must install the plugin. Run this in your backend folder:
npm run strapi install graphql
Your backend is live. Now that the engine is running, let’s go to Part B and build out our gaming review structure.
Part B: Build your Content Structure with Content-Type Builder
In Strapi, a Content-type acts as a blueprint. It defines exactly what fields your data should have (e.g., a "Review" needs a title and a rating). You will build a Collection Type, which allows you to create multiple entries of the same kind.
Step 1: Create a "Review" Collection Type
Navigate to your local admin panel at http://localhost:1337/admin. This is your command center for data management.
- Access the Builder: Click on Content-Type Builder in the main navigation.
- Initialize: Click Create new collection type.
-
Naming: Enter
Reviewfor the Display name and click Continue.
Step 2: Add Fields to Your Blueprint
We need to define what a "Review" looks like. Follow the table below to add the necessary fields:
| Field Type | Name | Settings |
|---|---|---|
| Text | title |
Advanced: Set as Required and Unique. |
| Number | rating |
Format: Integer. Advanced: Required, Min: 1, Max: 10. |
| Rich Text | body |
Advanced: Set as Required. |
- Finalize: Once all fields are added, click Finish, then click Save.
- Note: Strapi will automatically restart your server to apply these changes to the database.
Part C: Create and Publish Content
Now that the blueprint is ready, let's add some data.
- Go to the Content Manager in the left sidebar.
- Select the Review collection type you just created.
- Click Create new entry.
-
Fill the data: * Title: Mario Golf
- Body: [Your Review Text]
- Rating: 9
- Publish: Click Save, then click Publish.
Important: In Strapi, an entry must be Published to be accessible via the API. If it is only "Saved," it remains a draft and will return a 404 error when you try to fetch it.
Step 3: Set Permissions (Making the API Public)
By default, Strapi protects your data. To allow your frontend to see these reviews, we must grant public access.
- Go to Settings (⚙️) > Users & Permissions Plugin > Roles.
- Click on the Public role.
- Scroll to the Review section and expand it.
- Check the find and findOne boxes.
- Click Save.
PART D: Using Our API IN OUR NEXTJS APPLICATION:
Step 1: Initialize the Next.js Project:
Instead of manually configuring your environment, follow the official Nextjs Quick Start Guide. This ensures you are using the most stable version and the latest features.
Navigate back to your game-review folder to create the frontend, create your Next.js project.
npx create-next-app@latest frontend --use-npm # or --use-yarn
Let’s create an API route that fetches game reviews from Strapi and makes them available to our Next.js app
Create a new file named ./app/api/reviews/route.ts and add the following*:*
// Path: ./app/api/jobs/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const res = await fetch("http://localhost:1337/api/reviews");
const json = await res.json();
const reviews = json.map((review: any) => ({
...review,
id: review.documentId,
}));
return NextResponse.json(reviews);
}
List Reviews in Next.js Dashboard
Step 2: Define Typescript Interfaces for Your Game Review App:
Let's define the type structures for the review data we’ll be working with, to give us reliable typing, improved code readability, and helpful autocomplete support.
Create a file at: ./app/types/index.ts, and add the following:
export interface Review {
id: string | number;
documentId: string;
rating: number;
title: string;
body: Array<{
type: string;
children: Array<{
type: string;
text: string;
}>;
}>;
createdAt: string;
updatedAt: string;
publishedAt: string;
}
The Review interfaces gives structure and clarity to the data used in our frontend app:
-
Review: Represents a complete game review with its metadata and rich text content. It includes the review's unique identifier (idanddocumentId), the numeric rating score, a title, and a richly-formatted body composed of text blocks and inline elements. It also tracks three important timestamps—createdAt(when the review was written),updatedAt(when it was last modified), andpublishedAt(when it became publicly visible).
Step 3: Set Up the Global Layout & Header:
Next.js uses a special file called layout.tsx to define the UI shell that wraps around all your pages. This is the perfect place to set up global fonts, SEO metadata, and shared components like your navigation bar.
Open the ./app/layout.tsx file and replace the default code with this:
import type { Metadata } from "next";
import { Poppins } from "next/font/google";
import SiteHeader from "../components/SiteHeader";
import "./globals.css";
// Optimize Google Font loading
const poppins = Poppins({
subsets: ["latin"],
weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
});
export const metadata: Metadata = {
title: "Ninja Reviews",
description: "A site for ninja reviews",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={poppins.className}>
<div className="App">
<SiteHeader />
{children}
</div>
</body>
</html>
);
}
Here is what this code does:
-
Optimized Fonts: We import the
Poppinsfont fromnext/font/google. Next.js automatically optimizes this at build time so the browser doesn't have to make an extra network request to Google, speeding up your load times. -
SEO Metadata: The
metadataobject defines the global title and description for your app, which is essential for search engines and social media sharing. -
Global Components: We wrap the
{children}(which represents your individual pages) with a<SiteHeader />component. This ensures your navigation bar stays consistent across every single page without needing to import it multiple times.
Creating the Site Header Component
Because we just imported <SiteHeader /> into our layout, our app will throw an error if that file doesn't exist yet. Let's build it!
Create a new folder named components in the root of your project (alongside your app folder), and inside it, create a file named SiteHeader.tsx:
What is happening here?
Instead of using a standard HTML <a> tag, we are importing the <Link> component from Next.js. This enables lightning-fast client-side navigation. When a user clicks your site title to go back to the homepage, Next.js routes them instantly without doing a full page refresh!
Step 4: Create Homepage Component
To display reviews on the Next.js frontend, we’ll update our page component.
This component will fetch the reviews data from our Strapi backend, and render a card for each review with its key details.
Open ./app/page.tsx file and Add this code:
import Link from 'next/link';
import { Review } from '../types';
// Extract text content from rich text body
function getBodyText(body: Review['body']): string {
return body
.flatMap((block) => block.children.map((child) => child.text))
.join(' ');
}
export default async function Homepage() {
// Server-side fetching! No need for useEffect or loading states.
// Next.js handles this on the server before sending HTML to the browser.
try {
const res = await fetch('http://localhost:1337/api/reviews', {
// Optional: Add caching strategies here, e.g., cache: 'no-store'
});
if (!res.ok) throw new Error('Failed to fetch data');
const response = await res.json();
const data: Review[] = response.data || response;
return (
<div>
{data.map((review) => (
<div key={review.id} className="review-card">
<div className="rating">{review.rating}</div>
<h2>{review.title}</h2>
<small>console list</small>
<p>{getBodyText(review.body).substring(0, 200)}...</p>
<Link href={`/details/${review.id}`}>Read more</Link>
</div>
))}
</div>
);
} catch (error) {
return <p>Error loading reviews $ {(error as Error).message} :(</p>;
}
}
Here is a breakdown of what the code does:
-
Server-Side Fetching: Uses an async Next.js component to fetch game reviews directly, eliminating the need for
useEffectand loading states. -
Rich Text Parsing: Relies on a
getBodyTexthelper function to extract and stitch together raw text from Strapi's nested array format. - Review Cards: Maps through the fetched data to display the game's title, rating, and a clean 200-character excerpt.
-
Error Handling: Uses a
try...catchblock as a safety net to gracefully display a fallback message if the Strapi API goes down.
Step 5: Create the Dynamic Details Page
Create this directory structure:
./app/details/[id]/page.tsx
The [id] folder is Next.js syntax for dynamic routes—it captures the review ID from the URL and passes it to your component.
Implementation
Here's the code for page.tsx:
import { Review } from '../../../types';
// Extract text content from rich text body
function getBodyText(body: Review['body']): string {
return body
.flatMap((block) => block.children.map((child) => child.text))
.join(' ');
}
type DetailsPageProps = {
params: Promise<{ id: string }>;
};
export default async function ReviewDetails({ params }: DetailsPageProps) {
const resolvedParams = await params;
// Server-side fetching! No need for useEffect or loading states.
// Next.js handles this on the server before sending HTML to the browser.
try {
console.log(`Fetching review details for ID: ${resolvedParams.id}`); // Debug log to verify ID being fetched
const res = await fetch(`http://localhost:1337/api/reviews?filters[id][$eq]=${resolvedParams.id}`, {
// Optional: Add caching strategies here, e.g., cache: 'no-store'
});
if (!res.ok) throw new Error(`Failed to fetch data: ${res.status} ${res.statusText}`);
const response = await res.json();
const data: Review = Array.isArray(response.data) ? response.data[0] : response;
return (
<div key={data.id} className="review-card">
<h1>Review Details for ID: {resolvedParams.id}</h1>
<div className="rating">{data.rating}</div>
<h2>{data.title}</h2>
<small>console list</small>
<p>{getBodyText(data.body)}</p>
</div>
);
} catch (error) {
return <p>Error loading review details : {(error as Error).message} :(</p>;
}
}
Here is what the code above does:
-
Dynamic Route Parameter: The [id] in the folder name captures the review ID from the URL (e.g.,
/details/123extractsid: '123'). - Server-Side Fetching: This is an async server component that fetches the review directly from Strapi before rendering—no loading states needed.
-
Extract the ID: The component awaits
params, then usesresolvedParams.idto query/api/reviews?filters[id][$eq]=${id}. -
Render the Full Review: It displays the complete title, rating, and rich text body using the
getBodyText()helper function to flatten the nested body structure into readable text. - Error Handling: If the fetch fails, it displays a user-friendly error message.
This approach ensures that each review has its own permalink (e.g., /details/1, /details/2) and delivers the full content on initial page load.
Step 6: Add Basic Styling
The classes you’ve sprinkled through the JSX (className="review-card", .rating, etc.) don’t do anything until you give them some style.
Open globals.css and toss in (or merge with) a small snippet that treats each review like a card and makes the score badge stand out:
/* Note: The @import url() for Poppins was removed. We will handle it in layout.tsx */
body {
margin: 0 30px;
background: #f1f1f1;
}
h1,h2,h3,h4 {
font-weight: 500;
}
.App {
font-size: 1.2em;
margin: 10px auto;
width: 100%;
max-width: 1200px;
padding: 20px;
box-sizing: border-box;
}
a {
text-decoration: none;
color: #8e2ad6;
border-bottom: 1px dotted;
}
.site-header h1 {
font-size: 1.5em;
color: #8e2ad6;
padding-bottom: 10px;
border-bottom: 2px solid;
}
/* review list */
.review-card {
background: white;
margin: 60px auto;
padding: 1px 20px 20px 90px;
position: relative;
}
.review-card .rating {
position: absolute;
top: -20px;
left: -20px;
background: #8e2ad6;
font-size: 3em;
width: 90px;
height: 90px;
text-align: center;
color: white;
}
.review-card h2 {
margin-bottom: 0;
}
.review-card small {
margin-right: 10px;
color: #777;
}
Tip: this is all in globals.css because it applies across the app; feel free to tweak spacing, colors, or add shadows to suit your taste.
With those rules in place the grid of reviews will appear as distinct cards and the rating “badge” will really pop – much nicer than plain text!
Step 7: Run the Full-Stack Application
To see your project in action, you need to have both the Strapi Backend and the Next.js Frontend running at the same time. You can do this by opening two separate terminal windows or using a process runner.
Option 1: The Manual Way (Two Terminals)
Open two terminal tabs in your root game-review folder:
-
Terminal 1 (Backend):Bash
cd backend && npm run developAdmin panel will be at:
*http://localhost:1337/admin* -
Terminal 2 (Frontend):Bash
cd frontend && npm run devYour app will be live at:
http://localhost:3000
Getting Started with Next.js 15 and Strapi 5
This video provides a practical walkthrough of setting up a local environment for Next.js and Strapi 5, which perfectly complements the manual steps you've written.
Next Steps
Congratulations! You've built a full-stack Headless CMS application. To take this further, you could:
- Deploy: Push your backend to Strapi Cloud and your frontend to Vercel.
- Expand: Add a "Category" collection type to filter reviews by console (PS5, Xbox, Switch).
-
Switch to GraphQL: Now that you have the basics, try swapping the
fetchcalls for Apollo Client queries.
Top comments (0)