These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is tRPC?
tRPC is a TypeScript-first framework that enables fully type-safe APIs by leveraging TypeScript's type inference to share types directly between client and server without requiring separate API contracts, client code generation, or manual type synchronization. Instead of defining schemas or generating clients, tRPC treats your API procedures as directly-callable TypeScript functions.
The framework implements the Remote Procedure Call (RPC) pattern with three procedure types: queries for reading data, mutations for modifying data, and subscriptions for real-time data streams via WebSocket connections. Each procedure uses a builder pattern where you chain input validation (typically with Zod), define your handler function, and benefit from
TypeScript's automatic type inference for return types—enabling end-to-end type safety without code generation or manual type definitions. This approach differs significantly from working with REST APIs where types must be manually maintained.
// Server-side procedure
const articlesRouter = router({
getByDocumentId: publicProcedure
.input(z.object({ documentId: z.string() }))
.query(async ({ input }) => {
// In Strapi v5, documentId is a string identifier (not numeric id from v4)
// TypeScript knows input.documentId is a string
return await fetchArticle(input.documentId);
}),
});
// Client-side usage - fully typed
const article = await trpc.articles.getByDocumentId.query({ documentId: 'abc123' });
// TypeScript knows the exact shape of articleThe key differentiator from REST or GraphQL: tRPC requires no schema definition language separate from TypeScript, minimal code generation (optional with tools like Strapi's ts:generate-types), and no manual type maintenance. Your server code is your API contract, with runtime validation handled through Zod schemas that are defined directly alongside your procedures.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate tRPC with Strapi
Combining tRPC with Strapi creates a powerful stack where Strapi content models generate auto-generated TypeScript types that flow through tRPC router definitions, creating an end-to-end type-safe API layer. This integration enables your content types to serve as the foundation for a unified type system across your entire application, from backend data models to frontend components.
- End-to-end type safety without code generation: Strapi v5's TypeScript type generation (via
strapi ts:generate-types) produces types that developers import directly into tRPC procedures. Changes to content models immediately surface as compiler errors when the regenerated types no longer match tRPC procedure definitions, combining Strapi's automatic type generation with tRPC's compile-time type inference. - Simplified data fetching layer: Instead of manually constructing fetch calls with Strapi's REST API, you call typed procedures that handle query parameters, population, and filtering with full IntelliSense support.
- Built-in runtime validation: Zod schemas in tRPC procedures validate incoming requests before they hit your Strapi backend, catching malformed requests early and providing clear error messages.
- React Query integration: tRPC's native TanStack Query support gives you automatic caching, background refetching, and cache invalidation—critical for content-heavy applications where data freshness matters.
- Centralized authentication logic: tRPC middleware integrates with Strapi's Users & Permissions plugin, letting you implement JWT validation once and apply it consistently across protected procedures.
- Type Safety: When you use tRPC with Strapi's auto-generated TypeScript types, changes to your content type schemas are reflected in your procedure definitions. TypeScript will flag any incompatibilities between your tRPC procedures and updated Strapi content models at compile time, catching breaking changes before deployment.
How to Integrate tRPC with Strapi
This step-by-step guide walks you through setting up tRPC with Strapi, from installing dependencies to building type-safe procedures and configuring your frontend client.
Prerequisites
Before starting, ensure you have the following prerequisites installed and configured:
- Node.js 18 or higher installed
- A Strapi v5 project running locally or in production (quick start guide)
- A Next.js 14+ project with App Router support
- tRPC v10 or higher
- Basic familiarity with TypeScript and React
- An API token from your Strapi Admin Panel with appropriate permissions
Step 1: Install Dependencies
Start by adding the required packages to your Next.js project:
# Core tRPC dependencies
npm install @trpc/server @trpc/client @trpc/react-query @trpc/next
# Type validation with Zod
npm install zod
# HTTP client for custom implementation
npm install axios
# React Query for data fetching
npm install @tanstack/react-queryFor Strapi, enable TypeScript type generation by adding this configuration to config/typescript.js:
// config/typescript.js (in your Strapi project)
export default {
autogenerate: true, // Enables automatic type generation on server restart
};Run the type generation command to create initial types:
# Generate TypeScript types from Strapi v5 content-types
npm run strapi ts:generate-typesThis command automatically generates a types folder at the project root containing schema typings for all content-types, enabling end-to-end type safety when integrating with tRPC procedures.
Step 2: Configure the Strapi API Client
Configure an Axios instance to handle authentication and error formatting. This client sits between your tRPC procedures and Strapi's REST API, providing type-safe API calls with proper JWT token handling and standardized error responses.
// lib/strapi-client.ts
import axios from 'axios';
const strapiClient = axios.create({
baseURL: process.env.STRAPI_API_URL || 'http://localhost:1337/api',
headers: {
'Content-Type': 'application/json',
},
});
// JWT token interceptor for authenticated requests
strapiClient.interceptors.request.use((config) => {
const token = process.env.STRAPI_API_TOKEN;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor for error handling
strapiClient.interceptors.response.use(
(response) => response,
(error) => {
// Handle Strapi v5 API error responses with documentId string identifiers
const strapiError = {
status: error.response?.status,
message: error.response?.data?.error?.message || error.message,
details: error.response?.data?.error?.details,
};
return Promise.reject(strapiError);
}
);
export default strapiClient;Note that Strapi v5 uses documentId (a string) as the primary identifier for content, replacing the numeric id from v4. Additionally, Strapi v5 introduces a flattened response format where attributes appear at the top level rather than nested under a data.attributes wrapper. Keep both of these architectural changes in mind when building your procedures.
Step 3: Set Up tRPC Context
The context object provides shared resources—like your Strapi client, database connections, and authentication state—to every procedure within a request lifecycle. According to the tRPC Context Documentation, context is created per request and follows a two-tier architecture: inner context contains request-independent data (database clients and shared services), while outer context contains request-dependent data (user sessions and authorization tokens).
// server/context.ts
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import strapiClient from '../lib/strapi-client';
export async function createContext({ req, res }: CreateNextContextOptions) {
const token = req.headers.authorization?.replace('Bearer ', '');
return {
req,
res,
strapiClient,
token,
};
}
export type Context = inferAsyncReturnType<typeof createContext>;Step 4: Initialize tRPC with Middleware
Set up the tRPC instance with a custom error formatter and authentication middleware. The protected procedure validates JWT tokens according to Strapi's authentication system by making a request to Strapi's /users/me endpoint with the Bearer token, ensuring the user is authenticated before allowing access to protected operations.
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { Context } from './context';
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
strapiError: error.cause instanceof Error ? error.cause.message : undefined,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.token) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Authentication token required',
});
}
try {
const response = await ctx.strapiClient.get('/users/me', {
headers: {
Authorization: `Bearer ${ctx.token}`,
},
});
return next({
ctx: {
...ctx,
user: response.data,
},
});
} catch (error) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid or expired authentication token',
});
}
});Step 5: Build Content Routers
Now create routers that wrap Strapi's content operations. This example assumes you have an Article collection type in Strapi with fields like title, content, slug, and publishedAt.
// server/routers/articles.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
const articleInputSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(1, 'Content is required'),
slug: z.string().min(1, 'Slug is required'),
publishedAt: z.string().datetime().optional(),
});
const paginationSchema = z.object({
page: z.number().min(1).default(1),
pageSize: z.number().min(1).default(10),
});
export const articlesRouter = router({
getAll: publicProcedure.input(paginationSchema).query(async ({ ctx, input }) => {
try {
const response = await ctx.strapiClient.get('/articles', {
params: {
'pagination[page]': input.page,
'pagination[pageSize]': input.pageSize,
'sort[0]': 'publishedAt:desc',
},
});
// Strapi v5 returns flattened data (no data.attributes nesting)
return {
data: response.data.data,
meta: response.data.meta,
};
} catch (error: any) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch articles from Strapi',
cause: error,
});
}
}),
getByDocumentId: publicProcedure
.input(z.object({ documentId: z.string() }))
.query(async ({ ctx, input }) => {
try {
const response = await ctx.strapiClient.get(
`/articles/${input.documentId}`,
{ params: { populate: '*' } }
);
return response.data.data;
} catch (error: any) {
if (error.status === 404) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Article not found: ${input.documentId}`,
});
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to fetch article',
cause: error,
});
}
}),
create: protectedProcedure
.input(articleInputSchema)
.mutation(async ({ ctx, input }) => {
try {
const response = await ctx.strapiClient.post(
'/articles',
{ data: input },
{
headers: {
Authorization: `Bearer ${ctx.token}`,
},
}
);
return response.data.data;
} catch (error: any) {
if (error.status === 401 || error.status === 403) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Authentication required to create articles',
});
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to create article',
cause: error,
});
}
}),
update: protectedProcedure
.input(
z.object({
documentId: z.string(),
data: articleInputSchema.partial(),
})
)
.mutation(async ({ ctx, input }) => {
try {
const response = await ctx.strapiClient.put(
`/articles/${input.documentId}`,
{ data: input.data },
{ headers: { Authorization: `Bearer ${ctx.token}` } }
);
return response.data.data;
} catch (error: any) {
if (error.status === 404) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Article not found: ${input.documentId}`,
});
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to update article',
cause: error,
});
}
}),
delete: protectedProcedure
.input(z.object({ documentId: z.string() }))
.mutation(async ({ ctx, input }) => {
try {
await ctx.strapiClient.delete(`/articles/${input.documentId}`, {
headers: { Authorization: `Bearer ${ctx.token}` },
});
return { success: true, documentId: input.documentId };
} catch (error: any) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to delete article',
cause: error,
});
}
}),
});Step 6: Compose the Root Router
Combine your content routers using the router() helper function, composing them into a single appRouter, and then export the router's type for frontend type inference and client-side consumption.
// server/routers/_app.ts
import { router } from '../trpc';
import { articlesRouter } from './articles';
export const appRouter = router({
articles: articlesRouter,
});
export type AppRouter = typeof appRouter;Step 7: Create the API Route Handler
For Next.js, create an API route that handles tRPC requests:
// pages/api/trpc/[trpc].ts
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { appRouter } from '../../../server/routers/_app';
import { createContext } from '../../../server/context';
export default createNextApiHandler({
router: appRouter,
createContext,
onError({ error, type, path, input }) {
console.error(`tRPC Error - ${type} ${path}:`, error);
},
});Step 8: Configure the Frontend Client
Set up the tRPC client with React Query integration:
// utils/trpc.ts
import { createTRPCNext } from '@trpc/next';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../server/routers/_app';
function getBaseUrl() {
if (typeof window !== 'undefined') return '';
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export const trpc = createTRPCNext<AppRouter>({
config() {
return {
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
queryClientConfig: {
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
},
};
},
ssr: false,
});According to the tRPC Next.js integration documentation, the getBaseUrl() helper function determines the API endpoint URL based on the execution environment:
Environment Logic:
- Browser (Client-Side): Returns an empty string to use relative URLs for same-origin requests.
- Vercel Deployment: Uses the production domain from the
VERCEL_URLenvironment variable. - Local Development: Falls back to
http://localhost:3000(or a customPORTenvironment variable).
This pattern enables seamless deployment across development, staging, and production environments without manual configuration changes.
The httpBatchLink combines multiple simultaneous requests into single HTTP calls, potentially reducing HTTP requests by up to 90% in applications making multiple simultaneous queries.
Step 9: Wrap Your Application
Add the tRPC provider to your application:
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { trpc } from '../utils/trpc';
function App({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
export default trpc.withTRPC(App);Project Example: Type-Safe Blog with Article Management
Let's build a practical blog interface that demonstrates the full tRPC + Strapi workflow. This example shows article listing with pagination, individual article display, and authenticated content creation.
Setting Up the Strapi Content Type
First, create an Article collection type in Strapi with these fields using the Content-Type Builder:
title(Text, required)slug(UID, based on title)content(Rich Text)excerpt(Text, max 200 characters)coverImage(Media, single image) - managed through Media Libraryauthor(Relation to User)publishedAt(Datetime)
After creating the content type in Strapi, generate TypeScript types using the official Strapi v5 CLI command to automatically create type definitions from your content schemas:
npm run strapi ts:generate-typesThis command generates a types folder at your project root containing schema typings for all content-types. For automated type generation on every server restart, enable autogeneration in config/typescript.js:
export default {
autogenerate: true,
};Article List Component
This component fetches paginated articles and handles loading and error states:
// components/ArticleList.tsx
import { trpc } from '../utils/trpc';
import { useState } from 'react';
import Link from 'next/link';
export default function ArticleList() {
const [page, setPage] = useState(1);
const { data, isLoading, error, isFetching } = trpc.articles.getAll.useQuery({
page,
pageSize: 10,
});
if (isLoading) {
return <div className="loading">Loading articles...</div>;
}
if (error) {
return (
<div className="error">
<p>Failed to load articles: {error.message}</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
);
}
const { pagination } = data?.meta || {};
return (
<div className="article-list">
{data?.data.map((article) => (
<article key={article.documentId} className="article-card">
<Link href={`/articles/${article.documentId}`}>
<h2>{article.title}</h2>
</Link>
<p>{article.excerpt}</p>
<time dateTime={article.publishedAt}>
{new Date(article.publishedAt).toLocaleDateString()}
</time>
</article>
))}
<nav className="pagination">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || isFetching}
>
Previous
</button>
<span>
Page {page} of {pagination?.pageCount || 1}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={page >= (pagination?.pageCount || 1) || isFetching}
>
Next
</button>
</nav>
</div>
);
}Single Article View
Fetch and display a single article with populated relations using Strapi v5's REST API:
// Example: Fetch a single article with author and category relations populated
GET /api/articles/doc-id-123?populate=author&populate=categories
// Response structure (Strapi v5 flattened format)
{
"data": {
"id": 1, // Internal auto-generated integer
"documentId": "doc-id-123", // Primary unique identifier (string)
"title": "Article Title",
"content": "Article content...",
"publishedAt": "2024-01-15",
"author": { // Populated relation (not nested in attributes)
"documentId": "author-id-456",
"name": "John Doe",
"email": "john@example.com"
},
"categories": [ // Array of populated relations
{
"documentId": "cat-id-789",
"name": "Technology"
}
]
},
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 1
}
}
}According to the Strapi REST API documentation, relation population is opt-in by default. Use populate=author for single relations or populate[0]=author&populate[1]=categories for multiple relations. For nested population (relations of relations), use populate[author][populate][0]=avatar.
// pages/articles/[documentId].tsx
import { trpc } from '../../utils/trpc';
import { useRouter } from 'next/router';
export default function ArticlePage() {
const router = useRouter();
const documentId = router.query.documentId as string;
const { data: article, isLoading, error } = trpc.articles.getByDocumentId.useQuery(
{ documentId },
{ enabled: !!documentId }
);
if (!documentId || isLoading) {
return <div>Loading...</div>;
}
if (error) {
return (
<div className="error">
{error.data?.code === 'NOT_FOUND' ? (
<p>Article not found</p>
) : (
<p>Error loading article: {error.message}</p>
)}
</div>
);
}
return (
<article className="article-full">
<header>
<h1>{article?.title}</h1>
<time dateTime={article?.publishedAt}>
{article?.publishedAt &&
new Date(article.publishedAt).toLocaleDateString()}
</time>
</header>
<div
className="content"
dangerouslySetInnerHTML={{ __html: article?.content || '' }}
/>
</article>
);
}Article Creation Form
A form component for authenticated users to create new articles:
// components/CreateArticleForm.tsx
import { trpc } from '../utils/trpc';
import { useState } from 'react';
interface FormData {
title: string;
content: string;
excerpt: string;
}
export default function CreateArticleForm() {
const utils = trpc.useContext();
const [formData, setFormData] = useState<FormData>({
title: '',
content: '',
excerpt: '',
});
const createMutation = trpc.articles.create.useMutation({
onSuccess: (newArticle) => {
utils.articles.getAll.invalidate();
setFormData({ title: '', content: '', excerpt: '' });
alert(`Article created: ${newArticle.title}`);
},
onError: (error) => {
if (error.data?.code === 'UNAUTHORIZED') {
alert('Please log in to create articles');
} else {
alert(`Error: ${error.message}`);
}
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const slug = formData.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
createMutation.mutate({
title: formData.title,
content: formData.content,
slug,
publishedAt: new Date().toISOString(),
});
};
const updateField = (field: keyof FormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
return (
<form onSubmit={handleSubmit} className="create-article-form">
<div className="field">
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
value={formData.title}
onChange={(e) => updateField('title', e.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="excerpt">Excerpt</label>
<input
id="excerpt"
type="text"
value={formData.excerpt}
onChange={(e) => updateField('excerpt', e.target.value)}
maxLength={200}
/>
</div>
<div className="field">
<label htmlFor="content">Content</label>
<textarea
id="content"
value={formData.content}
onChange={(e) => updateField('content', e.target.value)}
rows={10}
required
/>
</div>
<button type="submit" disabled={createMutation.isLoading}>
{createMutation.isLoading ? 'Creating...' : 'Create Article'}
</button>
</form>
);
}Optimistic Updates for Better UX
For a smoother editing experience, implement optimistic updates that update the UI immediately while the server request processes:
// hooks/useUpdateArticle.ts
import { trpc } from '../utils/trpc';
export function useUpdateArticle() {
const utils = trpc.useContext();
return trpc.articles.update.useMutation({
onMutate: async (newData) => {
await utils.articles.getByDocumentId.cancel({
documentId: newData.documentId,
});
const previousArticle = utils.articles.getByDocumentId.getData({
documentId: newData.documentId,
});
utils.articles.getByDocumentId.setData(
{ documentId: newData.documentId },
(old) => (old ? { ...old, ...newData.data } : old)
);
return { previousArticle };
},
onError: (err, newData, context) => {
if (context?.previousArticle) {
utils.articles.getByDocumentId.setData(
{ documentId: newData.documentId },
context.previousArticle
);
}
},
onSettled: (data, error, variables) => {
utils.articles.getByDocumentId.invalidate({
documentId: variables.documentId,
});
},
});
}This pattern uses optimistic updates to immediately reflect changes in the UI, with error handling to revert the cache if the server rejects the request, and invalidation to ensure data consistency with the server state.
Strapi Open Office Hours
If you have any questions about Strapi 5 or integrating tRPC, join us for Strapi Open Office Hours, held Monday through Friday, from 12:30 pm to 1:30 pm CST. Get your questions answered live by the Strapi team and community experts.
For more details, visit the Strapi documentation and tRPC documentation.
Join us on Discord in the Open Office Hours channel for support and discussions.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.