Integrate TypeScript with Strapi to enhance code quality, scalability, and maintainability, while improving developer experience with strong typing and seamless frontend-backend collaboration
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.
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:
Data, Schema, UID, and Utils.ts:generate-types CLI 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.Core.Strapi), typed Document Service calls, and typed REST responses mean your editor knows what fields exist before you do.documentId-based identification.Before starting, confirm you have the following installed and ready:
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.
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"
]
}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 to title)content: Rich textpublishedAt: Automatically managed by StrapiAfter 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 componentsRun 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.
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);
}
}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: [],
},
},
});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.
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.
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'],
},
},
});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.
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 to title)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.
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-hereDefine 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;
}// 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();
}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}`;
}// 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>
);
}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.
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.
TypeScript is a superset of JavaScript that adds static typing, improving code quality and developer experience. When integrated with Strapi, TypeScript provides robust typing for content models, APIs, and custom extensions, making development more efficient and reducing runtime errors. It enhances productivity by offering autocompletion and better code navigation.
Integrating TypeScript with Strapi ensures better error handling, enhanced IDE support, and improved scalability. TypeScript helps developers manage large projects more easily by enforcing type safety, making refactoring and debugging simpler. It also improves team collaboration by providing clear contracts between the backend and frontend code.
To set up TypeScript with Strapi, you can either start a new Strapi project with the --typescript flag (npx create-strapi-app my-project --typescript) or convert an existing Strapi project by installing TypeScript and configuring the project structure. You'll need to adjust your file extensions from .js to .ts and set up a tsconfig.json file.
TypeScript adds type safety, which helps catch errors at compile time rather than runtime. This leads to more reliable and maintainable code. It also enhances the developer experience with better autocompletion, error checking, and refactoring tools, making TypeScript a powerful tool for larger, more complex Strapi projects.
You can consume Strapi's APIs using TypeScript by defining types that match your Strapi content models and making HTTP requests using libraries like axios or fetch. TypeScript ensures you get accurate types for the API response, preventing errors and improving development speed.
Yes, you can build custom Strapi plugins with TypeScript. By defining types for your plugin's methods, services, and content models, TypeScript ensures that your plugin is type-safe and integrates smoothly with Strapi's API and ecosystem.
TypeScript improves the developer experience by providing type safety, better code navigation, and autocompletion. It allows developers to work more efficiently by catching potential errors early and offering clear and concise code structures. With TypeScript, you also have automatic documentation through the types, improving team collaboration and onboarding.
To ensure full typing, use Strapi’s built-in type definitions and extend them as needed for custom models, controllers, and plugins. You can also use third-party type definitions or generate them automatically using Strapi’s API and GraphQL capabilities. Gradually update your codebase with proper types and use TypeScript's type-checking features to identify areas that need improvement.
TypeScript helps by providing compile-time type checks, making errors easier to catch early. For runtime errors, you can use try/catch blocks or custom error handling middleware to handle exceptions and ensure that Strapi returns meaningful error responses. TypeScript’s support for union types (e.g., Error | null) helps define precise error-handling logic.
Yes, you can use TypeScript with Strapi's GraphQL API. You can define types for the GraphQL queries and responses to ensure type safety. This is especially useful when dealing with complex queries and nested data, as TypeScript helps prevent mistakes and improve query structure.