Strapi
Strapi + Next.js: The Clean Integration Blueprint (REST + Types)
A repeatable way to connect Next.js to Strapi via REST with shared types and minimal coupling.

Connecting Next.js to Strapi with REST and TypeScript stays maintainable when you centralize the client, share types, and keep fetch logic in one place. This guide gives you a repeatable blueprint: one API client, types that match your content model, and consistent use of populate and query params—so you can build and scale without coupling or type drift.
One API client and base URL
Create a small client that knows the Strapi base URL and adds headers (e.g. Bearer token for draft content). Use env vars for the URL so you can point to different Strapi instances per environment. Centralizing the client avoids scattered fetch calls and makes it easy to add logging or retries later.
// lib/strapi.ts
const STRAPI_URL = process.env.STRAPI_URL ?? "http://localhost:1337";
export async function strapiFetch<T>(
path: string,
options?: RequestInit
): Promise<T> {
const url = path.startsWith("http") ? path : `${STRAPI_URL}/api${path}`;
const res = await fetch(url, {
...options,
headers: {
Authorization: process.env.STRAPI_TOKEN
? `Bearer ${process.env.STRAPI_TOKEN}`
: "",
...options?.headers,
},
});
if (!res.ok) throw new Error(`Strapi ${res.status}: ${path}`);
return res.json();
}Use this for all Strapi API calls so base URL and auth live in one spot.
Types that match your content types
Define TypeScript types for each Strapi content type (single types and collection types). Model the response shape: data for single or array, id, attributes, and relations as nested objects or arrays. You can generate these from Strapi’s schema or write them by hand and keep them in sync when you change the admin.
// types/strapi.ts
export interface StrapiImage {
id: number;
url: string;
alternativeText?: string;
width?: number;
height?: number;
}
export interface StrapiPost {
id: number;
attributes: {
title: string;
slug: string;
excerpt?: string;
content?: string;
publishedAt: string | null;
cover?: { data: StrapiImage | null };
author?: { data: { id: number; attributes: { name: string } } | null };
};
}
export interface StrapiResponse<T> {
data: T;
meta?: { pagination?: { page: number; pageCount: number; total: number } };
}Use these types in your fetch helpers and in Next.js components so you get autocomplete and catch mismatches early.
Populate and query params
Strapi REST uses populate to include relations and fields to limit attributes. Build query params in a helper or pass them explicitly so you don’t over-fetch. For list pages, add pagination (pagination[page], pagination[pageSize]) and filters (filters[slug][$eq]=hello) so the front end stays in control.
const params = new URLSearchParams({
"populate[cover]": "*",
"populate[author]": "name",
"filters[slug][$eq]": slug,
});
const post = await strapiFetch<StrapiResponse<StrapiPost[]>>(
`/posts?${params}`
);
const single = Array.isArray(post.data) ? post.data[0] : post.data;
if (!single) return null;
return single;Normalize the response (single vs array) in one place so the rest of the app always gets a consistent shape.
Summary
Use a single Strapi client with base URL and auth, define types that mirror your content types, and use populate and query params consistently. Normalize single vs array responses in one place so the rest of the app gets a consistent shape. That blueprint keeps your Strapi + Next.js integration clean and type-safe from first integration to large content models.