Next.js + Strapi is one of the best stacks for content-driven sites in 2026. Server Components, ISR, app router, and Strapi v5's clean REST API just click together.
But there's one part that still hurts: typing the data.
You ship your Strapi schema, then sit down in Next.js and… you're back to writing interfaces by hand. Then you forget to populate a relation and your category.name access blows up at runtime in a Server Component, which means a full page error instead of a clean TS warning.
Today we'll fix all of that — end to end — with a small open-source package called strapi-typed-client. By the end of this tutorial you'll have:
- A typed Strapi client with full autocomplete
- Types that regenerate automatically on
next devandnext build - Populate calls where TypeScript knows exactly which nested fields are available
- A real working Next.js 15 app reading from Strapi v5
No manual interfaces. No as any. No GraphQL.
Let's build it.
What we'll build
A tiny blog. Two content types in Strapi:
-
Article —
title,slug,body(rich text),cover(media),category(relation),author(relation) -
Category —
name,slug
And two pages in Next.js:
-
/— list of latest articles with cover image and category -
/articles/[slug]— full article with author bio
Standard stuff. The interesting part is everything in between.
Step 1 — Install the plugin on the Strapi side
In your Strapi v5 project:
npm install strapi-typed-client
Then enable it in config/plugins.ts:
// config/plugins.ts
export default {
'strapi-typed-client': {
enabled: true,
},
}
Restart Strapi. The plugin exposes your schema at GET /api/strapi-typed-client/schema plus a live /schema-watch SSE endpoint we'll use later.
That's the whole server side.
Step 2 — Install the same package in your Next.js app
npm install strapi-typed-client
Yes, the same package. It contains both the Strapi plugin and the Next.js client — the right entry point gets used depending on where you import from.
Step 3 — Hook it into next.config.ts
This is the part that does the magic:
// next.config.ts
import type { NextConfig } from 'next'
import { withStrapiTypes } from 'strapi-typed-client/next'
const nextConfig: NextConfig = {
// your existing Next.js config
}
export default withStrapiTypes({
strapiUrl: process.env.NEXT_PUBLIC_STRAPI_URL ?? 'http://localhost:1337',
token: process.env.STRAPI_TOKEN, // optional, only if your plugin requires auth
})(nextConfig)
What this does:
- On
next dev— connects to Strapi via SSE and regenerates types the moment your schema changes. Add a field in Strapi admin → TS knows about it before you tab back to your editor. - On
next build— runs a one-time sync generation before the build kicks off. - On
next start— does nothing (types are already in place).
No separate watcher process. No concurrently. No predev script. It just works as part of your normal Next.js lifecycle.
Step 4 — Generate types for the first time
You can let next dev do it automatically, or trigger it manually:
npx strapi-types generate --url http://localhost:1337
Generated files land in node_modules/strapi-typed-client/dist. (If you'd rather commit the generated files into your repo, use --format ts --output ./src/strapi — handy for monorepos.)
Step 5 — Build the home page
Here's the entire app/page.tsx:
// app/page.tsx
import Image from 'next/image'
import Link from 'next/link'
import { StrapiClient } from 'strapi-typed-client'
const strapi = new StrapiClient({
baseURL: process.env.NEXT_PUBLIC_STRAPI_URL ?? 'http://localhost:1337',
})
export default async function HomePage() {
const { data: articles } = await strapi.articles.find({
sort: ['publishedAt:desc'],
pagination: { page: 1, pageSize: 10 },
populate: {
cover: true,
category: true,
},
})
return (
<main>
<h1>Latest articles</h1>
<ul>
{articles.map((article) => (
<li key={article.documentId}>
<Link href={`/articles/${article.slug}`}>
{article.cover && (
<Image
src={article.cover.url}
alt={article.cover.alternativeText ?? ''}
width={400}
height={200}
/>
)}
<h2>{article.title}</h2>
<span>{article.category.name}</span>
</Link>
</li>
))}
</ul>
</main>
)
}
Pause and look at article.category.name. Try it in your editor. Hover over category — TypeScript knows it's the populated Category entity, not number | Category | undefined. Hover over cover — typed as Media, with url and alternativeText.
Now delete category: true from the populate object. TypeScript immediately marks article.category.name as an error: Property 'name' does not exist on type 'number'.
This is the part nobody else does. The whole point of Strapi's populate API is that the response shape depends on what you asked for. Most clients just type it as a union and shrug. This one infers the actual returned shape from your populate input.
Step 6 — Build the article detail page
// app/articles/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { StrapiClient } from 'strapi-typed-client'
const strapi = new StrapiClient({
baseURL: process.env.NEXT_PUBLIC_STRAPI_URL ?? 'http://localhost:1337',
})
export default async function ArticlePage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const { data: articles } = await strapi.articles.find({
filters: { slug: { $eq: slug } },
populate: {
cover: true,
category: true,
author: {
populate: { avatar: true },
},
},
})
const article = articles[0]
if (!article) notFound()
return (
<article>
<h1>{article.title}</h1>
<div>
<img src={article.author.avatar.url} alt={article.author.name} />
<span>by {article.author.name}</span>
</div>
<p>{article.body}</p>
</article>
)
}
Note the nested populate for author.avatar. The returned type is inferred all the way down — article.author.avatar.url is fully typed, but if you remove the inner populate: { avatar: true }, accessing .url becomes a TS error.
Step 7 — Filters and operators (also typed)
Strapi's filter operators ($eq, $contains, $gte, $or, etc.) are typed per field:
const { data } = await strapi.articles.find({
filters: {
title: { $contains: 'typescript' },
publishedAt: { $gte: '2026-01-01' },
$or: [
{ featured: { $eq: true } },
{ category: { slug: { $eq: 'editors-pick' } } },
],
},
})
Try writing views: { $contains: 'foo' } on a numeric field — TypeScript stops you. No more Invalid filter operator runtime errors from Strapi.
Step 8 — Caching and revalidation
Since the client uses the global fetch, all of Next.js's caching mechanics apply automatically. Pass a second argument to opt in:
// Always fresh
await strapi.articles.find({ /* params */ }, { cache: 'no-store' })
// ISR — revalidate every 60 seconds
await strapi.articles.find({ /* params */ }, { next: { revalidate: 60 } })
// Tag-based revalidation
await strapi.articles.find(
{ /* params */ },
{ next: { tags: ['articles'] } },
)
// Then somewhere in a Server Action:
import { revalidateTag } from 'next/cache'
revalidateTag('articles')
These options are fully typed — same shape Next.js's fetch accepts.
Step 9 — The "rename a field" moment
This is the moment that sells the whole thing.
- Keep
next devrunning. - Open Strapi admin in another tab. Rename
titletoheadlineon the Article content type. Save. - Tab back to VS Code.
Within a second or two, every article.title in your codebase lights up red. The watcher caught the schema change, regenerated types, and TypeScript re-checked your project.
No restart. No manual command. No "wait, what did the field used to be called?" archaeology.
This is what withStrapiTypes buys you, and it's the single biggest DX win in the whole package.
Step 10 — Handling errors properly
Server Components fail loudly when fetches throw. The client exports typed errors so you can handle them gracefully:
import { StrapiClient, StrapiError, StrapiConnectionError } from 'strapi-typed-client'
try {
const { data } = await strapi.articles.find()
return <ArticleList articles={data} />
} catch (error) {
if (error instanceof StrapiConnectionError) {
// Strapi is down — show a friendly fallback
return <BackendOffline />
}
if (error instanceof StrapiError && error.status === 401) {
// Token expired
return <NeedAuth />
}
throw error
}
Quick gotcha: as const for populate objects
If you extract a populate object to a variable, annotate it with as const — otherwise TS widens the literal trues to boolean, and the inference breaks:
// ❌ Wrong — inference breaks
const populate = { category: true, cover: true }
// ✅ Correct
const populate = { category: true, cover: true } as const
await strapi.articles.find({ populate })
Same pattern as satisfies for config objects. Once you know it, it's invisible.
What you got
Recap of what this 10-step tutorial set up:
- One package, two install points (Strapi + Next.js)
- Zero-config type generation tied to
next devandnext build - Live regeneration on schema changes via SSE
- Populate calls where the returned type follows the populate input
- Typed filters, sorting, pagination
- First-class Next.js caching options
- Typed error classes for failure handling
No manual interfaces. No GraphQL. No second watcher process. Your Strapi schema is the single source of truth, and your Next.js code knows about it without you doing anything.
Try it
npm install strapi-typed-client
That's the only command you need on either side — the same package powers both the Strapi plugin and the Next.js client.
Full docs live at boxlab-ltd.github.io/strapi-typed-client, source is on GitHub. If you run into anything weird, open an issue — I read all of them.
Now go rename a field in Strapi and watch TypeScript catch it. That part doesn't get old.
Top comments (0)