Turbopack

Integrate Turbopack with Strapi. Learn how to integrate Turbopack with Strapi 5 and Next.js 16. Set up typed fetch utilities, Server Components, Route Handlers, and ISR for a fast blog.

Turbopack  logo

Waiting on a bundler is one of those costs you stop noticing until it disappears. When you're iterating on content templates against a live Content Management System (CMS), a two-second rebuild on every save adds up to a meaningful chunk of your day. Turbopack, now the default bundler in Next.js 16, cuts that feedback loop down to something closer to instant.

This guide walks through wiring a Turbopack-powered Next.js 16 frontend to a Strapi 5 backend: environment setup, a typed fetch utility, Server Components, Route Handlers, and a working blog example with incremental static regeneration.

In brief:

  • Turbopack ships as the default bundler in Next.js 16 for both next dev and next build, so no flags or opt-in configuration are required.
  • Its Rust-based incremental computation model recomputes only the affected outputs on save, which is where content-heavy iteration against Strapi benefits most.
  • Strapi 5 returns fields at the top level of data and identifies entries by documentId, so your TypeScript interfaces differ from Strapi 4 patterns.
  • Relations, media, components, and Dynamic Zones return empty unless you request them explicitly with populate.

What Is Turbopack?

Turbopack is a Rust-based incremental bundler built directly into Next.js, developed by Vercel. Unlike webpack or Vite, it isn't a standalone, framework-agnostic tool; it's tightly coupled to Next.js and only available through it.

The core architecture relies on what the Next.js team calls "value cells" (Vc<...>), fine-grained pieces of execution similar to spreadsheet cells. When you save a file, Turbopack recomputes only the minimal set of affected outputs rather than rebuilding the entire dependency graph. This approach, combined with Rust's multi-core parallelization, is what drives its incremental computation speed.

Starting with Next.js 16, Turbopack is stable and used by default with both next dev and next build.

Comparison Table

webpackViteTurbopack
LanguageJavaScriptJavaScript, Go (esbuild)Rust
ArchitectureFull graph rebuildNative ESM, on-demandIncremental computation with value cells
Framework scopeFramework-agnosticFramework-agnosticNext.js only
HMR speedSlowFastFastest (per Vercel benchmarks)
Standalone useYesYesNo

Why Integrate Turbopack with Strapi

Building content-driven applications means constant iteration between your CMS backend and your frontend templates. Turbopack's performance gains hit exactly where that loop hurts most:

  • Sub-second content template updates. Turbopack delivers up to 96.3% faster Fast Refresh compared to webpack. When you're tweaking blog layouts or product card components against live Strapi data, changes appear almost instantly.
  • Faster server startup after branch switching. Local dev server startup is up to 76.7% faster. Switching branches with different Strapi content models no longer means a long wait before you can test.
  • Lower memory pressure during full-stack development. Vercel measured a 25% to 35% reduction in memory usage compared to webpack. When you're running a Next.js dev server, the Strapi backend, a database, and multiple browser tabs simultaneously, that headroom matters.
  • Rapid API integration testing. Strapi 5's Document Service API and complex populate queries require experimentation. Sub-second HMR turns testing different population strategies into a fluid process.
  • Smoother Draft and Publish workflows. Strapi 5's reworked Draft and Publish system, with content history, benefits from instant frontend updates when switching between draft and published states.
  • Faster caching strategy optimization. Tuning ISR revalidation times, cache tags, and React Server Component patterns requires rapid iteration, and Turbopack's fast feedback loop accelerates that work compared to webpack-based setups.

Those figures come from Vercel's internal measurements on their own large-scale applications. Smaller projects will see proportionally smaller but still measurable gains.

How to Integrate Turbopack with Strapi

Prerequisites

Before starting, confirm you have the following installed and ready:

  • Node.js 20 or higher (LTS). Strapi 5 supports Node.js 20, 22, and 24. Odd-numbered versions (21, 23) are not supported.
  • npm, yarn, or pnpm. Any package manager works.
  • A code editor with TypeScript support (VS Code recommended).
  • Basic familiarity with Next.js App Router and Strapi content modeling. If either is new to you, the getting started guide covers the fundamentals first.

Version Matrix:

PackageVersionNotes
Next.js16.1.6Turbopack is the default bundler
@strapi/strapi5.39.0Latest stable Strapi 5
qs6.xQuery string construction
Node.js24.14.0 LTSSatisfies both Next.js and Strapi

Step 1: Scaffold the Next.js Project

As of Next.js 16, Turbopack is the default bundler, so no flags are needed. The broader set of Next.js 16 features covers what else changed in that release.

# Terminal: project parent directory
npx create-next-app@latest my-strapi-app --typescript --tailwind --eslint --app
cd my-strapi-app

Install the query string builder:

# Terminal: my-strapi-app
npm install qs
npm install --save-dev @types/qs

This guide uses fetch directly so the request flow stays visible. If you prefer a typed client instead, the official @strapi/client SDK wraps the same REST endpoints.

Verify Turbopack is active by starting the dev server:

# Terminal: my-strapi-app
npm run dev
# Output will confirm: "▲ Next.js 16.1.6 (Turbopack)"

Step 2: Create the Strapi 5 Backend

In a separate terminal, scaffold a new Strapi project:

# Terminal: project parent directory
npx create-strapi-app@latest my-strapi-backend
cd my-strapi-backend
npm run develop

Once the Admin Panel loads at http://localhost:1337/admin, complete these four setup tasks:

  1. Create your content types using the Content-Type Builder.
  2. Add some content entries for testing.
  3. Enable public API access under Settings > Roles > Public.
  4. Generate an API token under Settings > Global Settings > API Tokens, choosing "Read-only" scope for the frontend.

The token approach used here follows the standard pattern for authenticating REST requests against a Strapi backend.

Step 3: Configure Environment Variables

Create a .env.local file in your Next.js project root:

# .env.local (Next.js project root)

# Server-only: never exposed to the browser
STRAPI_API_TOKEN=your_api_token_here

# Client-accessible (safe: only the base URL, not the token)
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337

Variables without the NEXT_PUBLIC_ prefix are server-side only, keeping your API token out of client bundles. For production deployments on Vercel, configure these through the environment variables dashboard.

Step 4: Configure next.config.js

The Next.js Image component requires explicit domain allowlisting for external images. Configure Strapi's upload domain plus Turbopack path aliases:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'http',
        hostname: 'localhost',
        port: '1337',
        pathname: '/uploads/**',
      },
      {
        // For Strapi Cloud deployments
        protocol: 'https',
        hostname: '**.strapiapp.com',
        pathname: '/uploads/**',
      },
    ],
  },

  turbopack: {
    resolveAlias: {
      '@': './src',
    },
  },
};

module.exports = nextConfig;

The turbopack key holds the Turbopack config, and path aliases defined there apply during Turbopack builds. Turbopack does not support arbitrary webpack configurations, so any custom webpack config in next.config.js only applies when Turbopack is not active.

Step 5: Create the Strapi Fetch Utility

This utility handles API token injection, query string construction with qs, and error handling. It runs server-side in Server Components and Route Handlers, keeping the token secure.

// lib/strapi.ts
import qs from 'qs';

const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;

interface FetchOptions extends RequestInit {
  params?: Record<string, unknown>;
}

export async function fetchStrapi<T>(
  path: string,
  options: FetchOptions = {}
): Promise<T> {
  const { params, ...fetchOptions } = options;

  const queryString = params
    ? `?${qs.stringify(params, { encodeValuesOnly: true })}`
    : '';

  const headers: HeadersInit = {
    'Content-Type': 'application/json',
    ...(STRAPI_API_TOKEN && {
      Authorization: `Bearer ${STRAPI_API_TOKEN}`,
    }),
    ...fetchOptions.headers,
  };

  const response = await fetch(`${STRAPI_URL}/api${path}${queryString}`, {
    ...fetchOptions,
    headers,
  });

  if (!response.ok) {
    throw new Error(
      `Strapi fetch error: ${response.status} ${response.statusText} at ${path}`
    );
  }

  return response.json();
}

Note that fetchStrapi prepends /api itself, so every path argument you pass should start at the collection name (/articles, not /api/articles).

A few other things worth knowing about this utility:

  • The qs library is recommended by Strapi's documentation for constructing complex queries with nested filters and population.
  • Strapi 5 returns no relations, media, components, or Dynamic Zones by default. Every non-scalar field requires an explicit populate parameter. Design each API call to declare exactly what it needs.
  • The fields parameter only works on scalar fields. For relational or media fields, use populate. This walkthrough of populate and filtering is worth a read if nested queries are new to you.

Step 6: Define TypeScript Types for Strapi 5 Responses

Strapi 5 eliminated the attributes wrapper from API responses. Fields now sit directly on the data object, and the stable documentId replaces numeric id for content identification:

// types/strapi.ts

interface MediaFormat {
  name: string;
  hash: string;
  ext: string;
  mime: string;
  width: number;
  height: number;
  size: number;
  url: string;
}

export interface StrapiMedia {
  id: number;
  documentId: string;
  name: string;
  alternativeText: string | null;
  caption: string | null;
  width: number;
  height: number;
  formats: {
    thumbnail?: MediaFormat;
    small?: MediaFormat;
    medium?: MediaFormat;
    large?: MediaFormat;
  };
  url: string;
  mime: string;
  size: number;
}

// Strapi 5: fields are top-level, not nested under .attributes
export interface Article {
  id: number;
  documentId: string;
  title: string;
  slug: string;
  content: string;
  excerpt: string | null;
  publishedAt: string;
  createdAt: string;
  updatedAt: string;
  locale: string;
  author?: {
    id: number;
    documentId: string;
    name: string;
    bio: string | null;
  };
  coverImage?: StrapiMedia;
  tags?: Array<{
    id: number;
    documentId: string;
    name: string;
    slug: string;
  }>;
}

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>;
}

This is the critical difference from Strapi 4. Where you previously wrote data.attributes.title, you now write data.title. And every content item carries a documentId, a stable 24-character string that persists across draft and published states and locale variations. If you're coming from an older project, the rundown of changes in Strapi 5 covers the rest of the response shape differences.

Step 7: Build a Server Component That Fetches Strapi Data

All components in the App Router are Server Components by default, which means your STRAPI_API_TOKEN stays on the server with no risk of browser exposure.

// app/page.tsx
import { fetchStrapi } from '@/lib/strapi';
import type { Article, StrapiListResponse } from '@/types/strapi';

export default async function HomePage() {
  const { data: articles } = await fetchStrapi<StrapiListResponse<Article>>(
    '/articles',
    {
      params: {
        populate: { coverImage: true, author: { fields: ['name'] } },
        sort: ['publishedAt:desc'],
        pagination: { pageSize: 10 },
      },
    }
  );

  return (
    <main>
      {articles.map((article) => (
        <div key={article.documentId}>
          <h2>{article.title}</h2>
          <p>{article.excerpt}</p>
        </div>
      ))}
    </main>
  );
}

Notice article.documentId as the React key instead of article.id. That's the Strapi 5 recommended approach, since documentId is stable across all content states.

Step 8: Add a Route Handler for Client-Side Fetches

If any client-side components need Strapi data, proxy requests through a Next.js Route Handler to keep the API token server-side:

// app/api/articles/route.ts
import { NextResponse } from 'next/server';
import { fetchStrapi } from '@/lib/strapi';

export async function GET() {
  try {
    const data = await fetchStrapi('/articles', {
      params: {
        populate: '*',
        sort: ['publishedAt:desc'],
        pagination: { page: 1, pageSize: 10 },
      },
    });
    return NextResponse.json(data);
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch articles' },
      { status: 500 }
    );
  }
}

Client components can then call /api/articles without needing direct access to Strapi or your API token.

Project Example: Content Blog with Next.js, Turbopack, and Strapi 5

Let's put everything together into a working blog. This project uses static generation with generateStaticParams, ISR for content freshness, and the Strapi REST API for data fetching.

Define the Strapi Content Type

Create the Article Collection Type in your Strapi project. You can define this through the Admin Panel's Content-Type Builder or directly in the schema file:

// src/api/article/content-types/article/schema.json
{
  "kind": "collectionType",
  "collectionName": "articles",
  "info": {
    "singularName": "article",
    "pluralName": "articles",
    "displayName": "Article",
    "description": "Blog article content type"
  },
  "options": {
    "draftAndPublish": true
  },
  "attributes": {
    "title": {
      "type": "string",
      "required": true
    },
    "slug": {
      "type": "uid",
      "targetField": "title",
      "required": true
    },
    "content": {
      "type": "richtext",
      "required": true
    },
    "excerpt": {
      "type": "text"
    },
    "author": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "api::author.author",
      "inversedBy": "articles"
    },
    "coverImage": {
      "type": "media",
      "multiple": false,
      "required": false,
      "allowedTypes": ["images"]
    },
    "tags": {
      "type": "relation",
      "relation": "manyToMany",
      "target": "api::tag.tag",
      "inversedBy": "articles"
    }
  }
}

Two details to watch here. First, publishedAt is managed by Draft and Publish, so you don't declare it as an attribute. Second, avoid naming any attribute status, locale, meta, or document, since those are reserved in Strapi 5.

Create Article API Helpers

Separate your data-fetching logic from your components. Each helper declares exactly which fields and relations it needs:

// lib/articles.ts
import { fetchStrapi } from '@/lib/strapi';
import type { Article, StrapiListResponse } from '@/types/strapi';

export async function getArticles(
  page = 1,
  pageSize = 10
): Promise<StrapiListResponse<Article>> {
  return fetchStrapi('/articles', {
    params: {
      populate: {
        coverImage: true,
        author: { fields: ['name'] },
        tags: { fields: ['name', 'slug'] },
      },
      sort: ['publishedAt:desc'],
      pagination: { page, pageSize },
      filters: { publishedAt: { $notNull: true } },
    },
  });
}

export async function getArticleBySlug(
  slug: string
): Promise<Article | null> {
  const response = await fetchStrapi<StrapiListResponse<Article>>('/articles', {
    params: {
      filters: { slug: { $eq: slug } },
      populate: {
        coverImage: true,
        author: { fields: ['name', 'bio'] },
        tags: { fields: ['name', 'slug'] },
      },
    },
  });
  return response.data[0] ?? null;
}

export async function getArticleSlugs(): Promise<string[]> {
  const response = await fetchStrapi<StrapiListResponse<Pick<Article, 'slug'>>>(
    '/articles',
    { params: { fields: ['slug'], pagination: { pageSize: 1000 } } }
  );
  return response.data.map((a) => a.slug);
}

The getArticleSlugs function fetches a minimal payload, just the slug field, since it's only used by generateStaticParams for build-time route generation. The filters documentation covers the full operator reference if you need more complex queries.

Build the Article Listing Page

This Server Component is statically generated and uses incremental static regeneration with a 60-second revalidation window, serving cached articles between revalidations:

// app/blog/page.tsx
import Link from 'next/link';
import Image from 'next/image';
import { getArticles } from '@/lib/articles';

export const revalidate = 60; // ISR: regenerate every 60 seconds

export default async function BlogPage() {
  const { data: articles, meta } = await getArticles();

  return (
    <main className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
      <p className="text-gray-500 mb-8">{meta.pagination.total} articles</p>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {articles.map((article) => (
          <article key={article.documentId} className="border rounded-lg overflow-hidden">
            {article.coverImage && (
              <div className="relative h-48 w-full">
                <Image
                  src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${article.coverImage.url}`}
                  alt={article.coverImage.alternativeText ?? article.title}
                  fill
                  className="object-cover"
                />
              </div>
            )}
            <div className="p-4">
              <h2 className="text-xl font-semibold mb-2">
                <Link href={`/blog/${article.slug}`} className="hover:text-blue-600">
                  {article.title}
                </Link>
              </h2>
              {article.excerpt && (
                <p className="text-gray-600 text-sm mb-3">{article.excerpt}</p>
              )}
              <div className="flex items-center justify-between text-xs text-gray-400">
                {article.author && <span>{article.author.name}</span>}
                <time dateTime={article.publishedAt}>
                  {new Date(article.publishedAt).toLocaleDateString('en-US', {
                    year: 'numeric',
                    month: 'short',
                    day: 'numeric',
                  })}
                </time>
              </div>
            </div>
          </article>
        ))}
      </div>
    </main>
  );
}

Build the Dynamic Article Page

The detail route pre-renders every published article at build time through generateStaticParams, then picks up new entries on the same 60-second revalidation cycle:

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getArticleBySlug, getArticleSlugs } from '@/lib/articles';

export const revalidate = 60;

export async function generateStaticParams() {
  const slugs = await getArticleSlugs();
  return slugs.map((slug) => ({ slug }));
}

export default async function ArticlePage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const article = await getArticleBySlug(slug);

  if (!article) {
    notFound();
  }

  return (
    <article className="container mx-auto max-w-3xl px-4 py-8">
      <h1 className="text-4xl font-bold mb-4">{article.title}</h1>
      {article.author && (
        <p className="text-gray-500 mb-8">By {article.author.name}</p>
      )}
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

In Next.js 16, params resolves as a promise, so the await params line is required rather than optional.

Final Project Structure

my-strapi-app/
├── app/
│   ├── api/
│   │   └── articles/
│   │       └── route.ts          # Route Handler (for client-side fetches)
│   ├── blog/
│   │   ├── page.tsx              # Article listing (Server Component)
│   │   └── [slug]/
│   │       └── page.tsx          # Dynamic article page
│   ├── layout.tsx
│   └── page.tsx
├── lib/
│   ├── strapi.ts                 # Base fetch utility
│   └── articles.ts               # Article-specific API helpers
├── types/
│   └── strapi.ts                 # TypeScript interfaces
├── .env.local                    # Environment variables
├── next.config.js                # Image domains and Turbopack config
└── tailwind.config.ts

Start both servers, Strapi on port 1337 and Next.js on port 3000, and Turbopack's sub-second HMR kicks in immediately as you iterate on your templates against live Strapi content.

Next Steps

With the local loop working, the remaining work is mostly deployment shape: promoting your environment variables, pointing remotePatterns at your production media domain, and deciding how aggressive your revalidation window should be. If you'd rather not manage the backend infrastructure yourself, Strapi Cloud handles hosting, media storage, and environment management for the Strapi side while your Next.js app deploys wherever you prefer.

Strapi Open Office Hours

If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.

For more details, visit the Strapi documentation and Turbopack documentation.

Frequently Asked Questions

No. Turbopack is a Next.js-specific bundler, so it affects your frontend app, not Strapi's Admin Panel. Strapi 5's Admin Panel bundlers default to Vite and can also run on webpack, and this guide to Vite and TypeScript covers that side of the stack. The separation is useful because each tool stays focused on its own job: Turbopack improves your Next.js development workflow, while Strapi handles content modeling, editorial work, and API delivery in its own build pipeline.

Yes. In Next.js 16, Turbopack is stable for both next dev and next build, and it's the default bundler for new apps. There's no official guidance endorsing this specific pairing as a supported production configuration, but Strapi communicates with your frontend over HTTP APIs, so it doesn't care which bundler builds the app. If your local setup works, production usually comes down to validating environment variables, image domains, caching behavior, and deployment-specific settings before you ship.

Strapi 5 introduces documentId as the stable identifier for content entries. Unlike numeric id, documentId remains consistent across draft and published states, which makes it a better fit for React keys and long-term content references. In a Next.js frontend, prefer documentId anywhere identity matters. You may still receive id in REST responses, but documentId is the safer reference for content-aware logic going forward.

For Strapi 5, the supported Node.js versions are 20, 22, and 24, while odd-numbered releases like 21 and 23 are not supported. If you're pairing Strapi with Next.js 16 and Turbopack, sticking to an LTS release keeps setup simpler and avoids unnecessary version drift. Node.js 24.14.0 LTS is a practical option because it satisfies both stacks, works well for local development, and makes production environments easier to align.

Strapi 5 does not include relations, media fields, components, or Dynamic Zones unless you explicitly request them with populate. That default keeps responses smaller and helps prevent over-fetching as your content model grows. If a relation appears to be missing, the query usually didn't declare it. The fix is to use populate for related data, fields for scalar values, and qs for building nested REST queries cleanly.