DEV Community

Cover image for Build a Type-Safe Next.js 15 + Strapi v5 Blog in 10 Minutes
Dearonski
Dearonski

Posted on

Build a Type-Safe Next.js 15 + Strapi v5 Blog in 10 Minutes

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 dev and next 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:

  • Articletitle, slug, body (rich text), cover (media), category (relation), author (relation)
  • Categoryname, 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
Enter fullscreen mode Exit fullscreen mode

Then enable it in config/plugins.ts:

// config/plugins.ts
export default {
  'strapi-typed-client': {
    enabled: true,
  },
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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' } } },
    ],
  },
})
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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.

  1. Keep next dev running.
  2. Open Strapi admin in another tab. Rename title to headline on the Article content type. Save.
  3. 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
}
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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 dev and next 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
Enter fullscreen mode Exit fullscreen mode

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)