These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is TypeScript?
TypeScript is a statically typed superset of JavaScript that adds optional type annotations, interfaces, and compile-time error checking to your codebase. Maintained by Microsoft, it compiles down to plain JavaScript and runs anywhere JavaScript does.
The current stable version in this guide is TypeScript 6.0.2, and features like generics, mapped types, and template literal types are particularly useful when modeling content management system (CMS) content schemas that change as your project evolves.TypeScript documentation
For full-stack developers building content-driven applications, TypeScript catches mismatched data shapes, missing fields, and incorrect API payloads before they hit production. IDEs like VS Code provide autocompletion and inline error highlighting based on your type definitions, which means fewer round trips to the browser console when something breaks.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate TypeScript with Strapi
Strapi v5 made a deliberate architectural commitment to TypeScript—the entire framework codebase is now almost entirely written in TypeScript, not just supported. This means the types you consume from @strapi/strapi are first-class, not bolted on. Here's what that gives you:
- Type-safe content modeling. Your controllers, services, and lifecycle hooks get compile-time validation against content-type schemas via structured namespaces like
Data,Schema,UID, andUtils. - Auto-generated types from content models. The
ts:generate-typesCLI command produces TypeScript declarations for every content-type and component you create in the Admin Panel, keeping backend code in sync with your data layer. - Autocompletion across the full stack. Typed Strapi instances (
Core.Strapi), typed Document Service calls, and typed REST responses mean your editor knows what fields exist before you do. - Safer API consumption on the frontend. TypeScript interfaces that mirror your Strapi content types catch response-shape mismatches early, especially with v5's flattened response format and
documentId-based identification. - Reduced context switching. A single language across your Strapi backend, frontend framework, and shared type definitions eliminates the cognitive overhead of juggling JavaScript's loose typing in one layer and TypeScript's strict typing in another.
- Confidence during content model changes. When you rename a field or restructure a component, regenerating types surfaces every downstream reference that needs updating, not just the ones you remember.
How to Integrate TypeScript with Strapi
Prerequisites
Before starting, confirm you have the following installed and ready:
- Node.js v18 or v20 (LTS recommended)
- npm, yarn, or pnpm as your package manager
- A code editor with TypeScript support (VS Code recommended for IntelliSense)
- Basic familiarity with TypeScript syntax (interfaces, generics, type imports)
- No prior Strapi installation required. If you already have one running, you can still follow along.
Step 1: Scaffold a New Strapi v5 Project
New Strapi v5 projects can be created in TypeScript by using the --typescript flag. Run the following command and the interactive CLI will prompt you for configuration options:
npx create-strapi@latest my-strapi-appFor CI/CD pipelines or scripted setups where you need to skip prompts:
npx create-strapi@latest my-strapi-app --typescript --non-interactive --skip-cloudThe --typescript flag ensures the project is scaffolded with TypeScript. Use --js if you specifically need JavaScript instead.
Other useful flags:
| Flag | Description |
|---|---|
--no-run | Skip auto-starting the app after creation |
--install | Install dependencies without prompting |
--skip-db | Use default SQLite, skip database prompts |
--use-npm / --use-yarn / --use-pnpm | Force a specific package manager |
After scaffolding completes, build and start the development server:
cd my-strapi-app
npm run build
npm run developThe build command compiles TypeScript to JavaScript in the ./dist directory. The develop command starts the dev server and opens the Admin Panel for initial setup.
Step 2: Understand the TypeScript Project Structure
A scaffolded TypeScript project includes two tsconfig.json files and several TypeScript-specific directories. You generally won't need to touch the base configs unless you're adding path aliases or custom compiler options. Here's the relevant structure:
my-strapi-app/
├── config/
│ ├── admin.ts
│ ├── api.ts
│ ├── database.ts
│ ├── middlewares.ts
│ ├── plugins.ts
│ ├── server.ts
│ └── typescript.ts # Strapi-specific TS feature config
├── dist/ # compiled TypeScript output
├── src/
│ ├── admin/
│ │ └── tsconfig.json # admin panel TS configuration
│ ├── api/ # your content-type logic lives here
│ ├── components/
│ ├── extensions/
│ ├── middlewares/
│ └── index.ts # application entry point
├── types/
│ └── generated/ # output of ts:generate-types
│ ├── components.d.ts
│ └── contentTypes.d.ts
├── package.json
└── tsconfig.json # server TS configurationBoth tsconfig.json files extend base configurations from the @strapi/typescript-utils package, which is installed automatically. You don't need to specify core compiler options; they're inherited.
The root tsconfig.json handles server-side compilation:
{
"extends": "@strapi/typescript-utils/tsconfigs/server",
"compilerOptions": {
"outDir": "dist",
"rootDir": "."
},
"include": [
"./",
"src/**/*.json"
],
"exclude": [
"node_modules/",
"build/",
"dist/",
".cache/",
".tmp/",
"src/admin/",
"**/*.test.ts",
"src/plugins/**"
]
}The admin panel config at src/admin/tsconfig.json handles the frontend admin UI:
{
"extends": "@strapi/typescript-utils/tsconfigs/admin",
"include": [
"../plugins/**/admin/src/**/*",
"./"
],
"exclude": [
"node_modules/",
"build/",
"dist/",
"**/*.test.ts"
]
}Step 3: Create Content Types and Generate TypeScript Declarations
Open the Admin Panel at http://localhost:1337/admin, create your account, and build your content types using the Content-Type Builder. For this guide, create an Article collection type with these fields:
title: Text (Short text)slug: UID (linked totitle)content: Rich textpublishedAt: Automatically managed by Strapi
After saving your content-type, Strapi restarts the server. Now generate the TypeScript declarations:
npm run strapi ts:generate-typesAdd the --debug flag to see a detailed table of every generated schema:
npm run strapi ts:generate-types --debugThis creates (or updates) two files in types/generated/:
contentTypes.d.ts: type declarations for your content typescomponents.d.ts: type declarations for your components
Run this command every time you modify a content model. It's easy to forget this step and then wonder why your types don't match your schema. The generated types won't update themselves unless you enable the experimental auto-generation feature in config/typescript.ts:
// config/typescript.ts
export default ({ env }) => ({
autogenerate: true,
});⚠️ The official docs flag this as experimental. It might cause issues or break features. Use it in development, but regenerate manually before deploying.
Step 4: Use Strapi's Type Namespaces in Backend Code
Strapi v5 provides four structured type namespaces accessible from @strapi/strapi:
| Namespace | Role | Example |
|---|---|---|
Data | Entity data types | Data.ContentType<'api::article.article'> |
Schema | Schema definitions | Schema.Component |
UID | Unique identifier types | UID.ContentType, UID.Schema |
Utils | Utility types | Utils.String.Dict<T> |
Here's how to type the Strapi instance in your application's register or bootstrap lifecycle:
// src/index.ts
import type { Core } from '@strapi/strapi';
export default {
register({ strapi }: { strapi: Core.Strapi }) {
// strapi is fully typed here
},
};For parametrized content-type documents, where you know the exact UID, the type system narrows available fields:
import type { Data } from '@strapi/strapi';
function validateArticle(article: Data.ContentType<'api::article.article'>) {
const { title, category } = article;
// ^? string ^? Data.ContentType<'api::category.category'>
if (title.length < 5) {
throw new Error('Title too short');
}
}When the content-type UID isn't known at compile time, use the generic (non-parametrized) form and check properties with type guards:
import type { Data } from '@strapi/strapi';
async function save(name: string, document: Data.ContentType) {
// Only system fields are guaranteed (id, documentId, createdAt, etc.)
if ('title' in document) {
console.log(document.title);
}
}Step 5: Build Typed Controllers and Services
Strapi provides TypeScript support through explicit project setup and dedicated TypeScript-related commands. To generate a custom controller:
npx strapi generateSelect "controller" from the interactive menu. Here's a typed controller that wraps a core action with custom query logic:
// src/api/article/controllers/article.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article', ({ strapi }) => ({
async find(ctx) {
ctx.query = { ...ctx.query, local: 'en' };
const { data, meta } = await super.find(ctx);
meta.date = Date.now();
return { data, meta };
},
}));For a controller that replaces the core action entirely and includes sanitization:
// src/api/article/controllers/article.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::article.article', ({ strapi }) => ({
async find(ctx) {
await this.validateQuery(ctx);
const sanitizedQueryParams = await this.sanitizeQuery(ctx);
const { results, pagination } = await strapi
.service('api::article.article')
.find(sanitizedQueryParams);
const sanitizedResults = await this.sanitizeOutput(results, ctx);
return this.transformResponse(sanitizedResults, { pagination });
},
}));Typed services follow the same factory pattern:
// src/api/article/services/article.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::article.article', ({ strapi }) => ({
async find(...args) {
const { results, pagination } = await super.find(...args);
results.forEach(result => {
result.counter = 1;
});
return { results, pagination };
},
}));And typed routes can restrict which core endpoints are exposed:
// src/api/article/routes/article.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::article.article', {
only: ['find', 'findOne'],
config: {
find: {
auth: false,
policies: [],
middlewares: [],
},
},
});Step 6: Use the Document Service API with TypeScript
The Document Service API replaces the deprecated v4 Entity Service API. It's the server-side API you use within controllers, services, and lifecycle hooks. One critical change is that documentId (a string) is now the primary document identifier, not the numeric id.
// Inside a custom controller or service
// Find published articles
const articles = await strapi.documents('api::article.article').findMany({
status: 'published',
filters: { locale: 'en' },
sort: ['publishedAt:desc'],
pagination: { page: 1, pageSize: 10 },
});
// Find a single article by documentId
const article = await strapi.documents('api::article.article').findOne({
documentId: 'a1b2c3d4e5f6g7h8i9j0klm',
status: 'published',
fields: ['title', 'content', 'slug'],
populate: { cover: true, author: true },
});
// Create a new article
const newArticle = await strapi.documents('api::article.article').create({
data: {
title: 'Getting Started with TypeScript',
content: 'Article content here...',
},
});
// Update an existing article
const updated = await strapi.documents('api::article.article').update({
documentId: 'a1b2c3d4e5f6g7h8i9j0klm',
data: { title: 'Updated Title' },
});
// Publish a draft
await strapi.documents('api::article.article').publish({
documentId: 'a1b2c3d4e5f6g7h8i9j0klm',
});findOne() and findFirst() return the draft version by default. Pass status: 'published' explicitly when you need published content.
Step 7: Type Your REST API Responses for Frontend Consumption
Strapi v5 uses a flattened response format. The v4 attributes wrapper is gone, and fields sit at the top level alongside documentId. Define TypeScript interfaces on the frontend that reflect this:
// types/strapi.ts
export interface StrapiDocument {
id: number;
documentId: string;
locale?: string;
createdAt?: string;
updatedAt?: string;
publishedAt?: string | null;
}
export interface StrapiListResponse<T extends StrapiDocument> {
data: T[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface StrapiSingleResponse<T extends StrapiDocument> {
data: T;
meta: Record<string, unknown>;
}Then define your content-type interface:
// types/article.ts
import { StrapiDocument } from './strapi';
export interface Article extends StrapiDocument {
title: string;
slug: string;
content: string;
publishedAt: string | null;
locale: string;
}Build a typed fetch helper that uses these interfaces:
// lib/strapi-client.ts
import type { Article } from '@/types/article';
import type {
StrapiListResponse,
StrapiSingleResponse,
} from '@/types/strapi';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL ?? 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN ?? '';
async function strapiGet<T>(path: string, params?: Record<string, unknown>): Promise<T> {
const url = new URL(`${STRAPI_URL}/api${path}`);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, JSON.stringify(value));
}
});
}
const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) throw new Error(`Strapi fetch failed: ${res.status}`);
return res.json() as Promise<T>;
}
export async function getArticles(params?: Record<string, unknown>) {
return strapiGet<StrapiListResponse<Article>>('/articles', params);
}
export async function getArticle(documentId: string) {
return strapiGet<StrapiSingleResponse<Article>>(`/articles/${documentId}`);
}Note that the official Strapi docs acknowledge that using Strapi's server-side generated types directly in a frontend app requires workarounds. The recommended approach is to use Strapi's generated types (or schema-based type generation) so your frontend types mirror your content-type fields automatically, rather than hand-crafting interfaces.
Step 8: Add Typed Policies and Middlewares
Policies use PolicyError from @strapi/utils (not @strapi/strapi):
// src/policies/is-authenticated.ts
import { errors } from '@strapi/utils';
export default async (policyContext, config, { strapi }) => {
const { user } = policyContext.state;
if (!user) {
throw new errors.PolicyError('You must be logged in', {
policy: 'is-authenticated',
});
}
return true;
};Middlewares can access the Document Service to check authorization:
// src/middlewares/is-owner.ts
export default (config, { strapi }) => {
return async (ctx, next) => {
const user = ctx.state.user;
const entryId = ctx.params.id ? ctx.params.id : undefined;
let entry = {};
if (entryId) {
entry = await strapi
.documents('api::article.article')
.findOne({ documentId: entryId, populate: '*' });
}
if (user.id !== entry.author.id) {
return ctx.unauthorized('This action is unauthorized.');
} else {
return next();
}
};
};Attach it to a route:
// src/api/article/routes/article.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::article.article', {
config: {
update: {
middlewares: ['api::article.is-owner'],
},
},
});Project Example: Typed Blog with Strapi v5 and Next.js
This project demonstrates end-to-end TypeScript usage: typed content models in Strapi, typed API consumption in Next.js, and a typed image upload function. The pattern is based on the official Strapi developer blog tutorial.
Strapi Backend Setup
Scaffold and start the Strapi project:
npx create-strapi@latest blog-backend --typescript --non-interactive --skip-cloud
cd blog-backend
npm run build
npm run developIn the Admin Panel, create a Blog collection type with:
title: Text (Short text)slug: UID (linked totitle)content: Rich textcover: Media (Single media)
After saving, generate types:
npm run strapi ts:generate-types --debugSet up an API token in the Admin Panel under Settings → API Tokens → Create new API token. Choose Read-only for the token type and Unlimited for duration.
For type-safe query validation, configure config/api.ts:
// config/api.ts
export default ({ env }: { env: (key: string) => string }) => ({
rest: {
defaultLimit: 100,
maxLimit: 250,
strictParams: true,
},
documents: {
strictParams: true,
},
});Setting strictParams: true rejects unknown query parameters at both the REST and Document Service layers, a safety net that pairs well with TypeScript's compile-time checks.
Next.js Frontend Setup
Create the Next.js project alongside your Strapi app:
npx create-next-app@latest blog-frontend --typescript --app
cd blog-frontendAdd environment variables in .env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-token-hereShared Type Definitions
Define the response types and content-type interface:
// types/strapi.ts
export interface StrapiDocument {
id: number;
documentId: string;
locale?: string;
createdAt?: string;
updatedAt?: string;
publishedAt?: string | null;
}
export interface StrapiListResponse<T extends StrapiDocument> {
data: T[];
meta: {
pagination: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface StrapiSingleResponse<T extends StrapiDocument> {
data: T;
meta: Record<string, unknown>;
}// types/blog.ts
import { StrapiDocument } from './strapi';
interface MediaFile {
id: number;
documentId: string;
url: string;
alternativeText?: string;
width?: number;
height?: number;
}
export interface BlogPost extends StrapiDocument {
title: string;
slug: string;
content: string;
cover?: MediaFile;
}Typed API Client
// lib/api.ts
import type { BlogPost } from '@/types/blog';
import type { StrapiListResponse, StrapiSingleResponse } from '@/types/strapi';
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL ?? 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN ?? '';
export async function getAllPosts(): Promise<StrapiListResponse<BlogPost>> {
const res = await fetch(
`${STRAPI_URL}/api/blogs?populate=cover&sort=publishedAt:desc`,
{
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
'Content-Type': 'application/json',
},
next: { revalidate: 60 },
}
);
if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`);
return res.json();
}
export async function getPostBySlug(
slug: string
): Promise<StrapiSingleResponse<BlogPost>> {
const res = await fetch(
`${STRAPI_URL}/api/blogs?filters[slug][$eq]=${slug}&populate=cover`,
{
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
'Content-Type': 'application/json',
},
next: { revalidate: 60 },
}
);
if (!res.ok) throw new Error(`Failed to fetch post: ${res.status}`);
return res.json();
}Typed Image Utility
Strapi stores media URLs as relative paths when using local uploads. This utility resolves them:
// lib/media.ts
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL ?? '';
export function getStrapiMedia(url: string | null): string | null {
if (url == null) return null;
if (url.startsWith('data:')) return url;
if (url.startsWith('http') || url.startsWith('//')) return url;
return `${STRAPI_URL}${url}`;
}Blog Listing Page
// app/page.tsx
import { getAllPosts } from '@/lib/api';
import { getStrapiMedia } from '@/lib/media';
import type { BlogPost } from '@/types/blog';
import Link from 'next/link';
import Image from 'next/image';
export default async function HomePage() {
const { data: posts, meta } = await getAllPosts();
return (
<main>
<h1>Blog</h1>
<p>{meta.pagination.total} posts</p>
{posts.map((post: BlogPost) => (
<article key={post.documentId}>
{post.cover && (
<Image
src={getStrapiMedia(post.cover.url) ?? ''}
alt={post.cover.alternativeText ?? post.title}
width={post.cover.width ?? 800}
height={post.cover.height ?? 400}
/>
)}
<h2>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h2>
<time>{post.publishedAt}</time>
</article>
))}
</main>
);
}Typed Image Upload (for Admin Features)
If your frontend includes content creation capabilities, here's a typed upload function that works with Strapi v5's upload API:
// lib/upload.ts
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL ?? 'http://localhost:1337';
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN ?? '';
export async function uploadCoverImage(image: File, refId: number) {
const formData = new FormData();
formData.append('files', image);
formData.append('ref', 'api::blog.blog');
formData.append('refId', refId.toString());
formData.append('field', 'cover');
const response = await fetch(`${STRAPI_URL}/api/upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${STRAPI_TOKEN}`,
},
body: formData,
});
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
return response.json();
}Notice the ref field uses the content-type UID format api::blog.blog, the same string literal your backend types use, which keeps both sides of the stack aligned.
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, at 12:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and TypeScript documentation.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.