These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Elysia?
Elysia is a TypeScript-first web framework built for the Bun runtime, currently at stable version 1.4.25. Its defining feature is automatic type inference: you define validation schemas once using the built-in t utility, and TypeScript infers types from them without manual annotations.
The framework uses an instance-based architecture where routes, middleware, and lifecycle hooks chain together fluently. Under the hood, a custom JIT compiler called Sucrose performs static code analysis to optimize route handling at runtime.
Elysia also includes Eden Treaty, which generates type-safe clients from your server definitions without code generation steps. Official plugins cover CORS, JWT, OpenAPI documentation, and more, all composable through the .use() method.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Elysia with Strapi
Pairing Elysia with Strapi v5 gives you a content-managed backend with a high-performance, type-safe API layer in front of it. Here's what makes the combination practical:
- Decoupled architecture with clear boundaries. Strapi handles content modeling, media management, and editor workflows. Elysia handles custom business logic, request validation, and API composition. Each service scales independently.
- Type-safe content consumption. Elysia's TypeBox validation lets you define response schemas that validate Strapi data at runtime while generating TypeScript types at compile time, with no manual type maintenance.
- Bun's native fetch eliminates HTTP client dependencies. No
axios, nonode-fetch. Bun's global fetch API handles all communication with Strapi's REST endpoints out of the box. - Flexible content modeling through Strapi's Admin Panel. Content editors create and manage Collection Types, media assets, and localized content without touching code.
- Plugin ecosystems on both sides. Elysia's middleware plugins handle CORS, rate limiting, and authentication, while Strapi's marketplace and integrations extend CMS functionality.
- Production-ready authentication. Strapi's API tokens provide granular, permission-scoped access. Elysia's JWT plugin adds an additional authentication layer for your client-facing API.
How to Integrate Elysia with Strapi
Prerequisites
Before starting, make sure you have the following installed and ready:
- Bun (latest stable) — install from bun.sh
- Node.js v20+ or v22+, required for running Strapi v5 (installation prerequisites)
- Strapi v5 project initialized and running
- Basic familiarity with TypeScript and REST APIs
- A terminal and code editor
One important constraint to understand upfront: Strapi v5 cannot run on Bun. The Strapi core depends on Koa.js, specific database drivers, and native modules like Sharp that aren't fully compatible with Bun's runtime. You'll run Strapi on Node.js and Elysia on Bun as separate processes communicating over HTTP.
Step 1: Initialize the Strapi v5 Project
Start by creating a fresh Strapi v5 instance. Open your terminal and run:
npx create-strapi@latest my-strapi-projectThe interactive CLI walks you through database selection (SQLite works fine for development) and package manager choice. Once installation completes:
cd my-strapi-project
npm run developThis launches the Admin Panel at http://localhost:1337/admin. Create your first administrator account when prompted.
Step 2: Create a Content-Type in Strapi
Navigate to the Content-Type Builder in the Admin Panel. For this integration, create an "Article" Collection Type with these fields:
| Field Name | Type | Details |
|---|---|---|
| title | Text | Short text, required |
| slug | UID | Attached to title field |
| content | Rich text | Main article body |
| publishedAt | DateTime | Auto-managed by Strapi |
After saving, Strapi automatically generates REST endpoints at /api/articles. The Content API documentation confirms these endpoints follow the pattern /api/<api-id-plural>.
Add a few test articles through the Content Manager, making sure to publish them.
Step 3: Generate a Strapi API Token
Navigate to Settings → Global Settings → API Tokens in the Admin Panel. Create a new token with these settings:
- Name:
Elysia Backend Token - Token duration: Unlimited (for development; set expiration in production)
- Token type: Read-only (since the Elysia layer only needs to fetch content initially)
Copy the generated token immediately. Strapi only displays it once. Store it somewhere safe; you'll add it to your environment variables next.
The API tokens documentation covers the full range of permission configurations, including custom per-Collection Type access.
Step 4: Configure CORS in Strapi
Your Elysia application needs permission to make requests to Strapi. Edit config/middlewares.js (or .ts) in your Strapi project:
module.exports = [
'strapi::logger',
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'connect-src': ["'self'", 'https:'],
'img-src': [
"'self'",
'data:',
'blob:',
'market-assets.strapi.io',
],
'media-src': [
"'self'",
'data:',
'blob:',
'market-assets.strapi.io',
],
upgradeInsecureRequests: null,
},
},
},
},
{
name: 'strapi::cors',
config: {
origin: ['http://localhost:3000', 'https://your-elysia-app.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
keepHeaderOnError: true,
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];Add your Elysia application's URL to the origin array. The Authorization header must be included so API token requests aren't blocked. Restart Strapi after saving.
Step 5: Set Up the Elysia Project
In a separate directory from your Strapi project, initialize the Elysia application:
mkdir elysia-api && cd elysia-api
bun init -y
bun add elysia @elysiajs/corsCreate a .env file at the project root with your Strapi connection details:
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your_api_token_from_step_3
PORT=3000Bun automatically loads .env files without requiring dotenv, so these values are available through Bun.env immediately.
For TypeScript safety, add type declarations for your environment variables. Create env.d.ts:
declare module "bun" {
interface Env {
STRAPI_URL: string;
STRAPI_API_TOKEN: string;
PORT: string;
}
}Step 6: Build a Strapi Service Layer
Rather than scattering fetch calls across route handlers, encapsulate all Strapi communication in a dedicated service class. Create src/services/strapi.ts:
import { Elysia } from 'elysia';
class StrapiService {
private baseUrl: string;
private token: string;
constructor() {
const baseUrl = Bun.env.STRAPI_URL;
const token = Bun.env.STRAPI_API_TOKEN;
if (!baseUrl || !token) {
throw new Error('Missing STRAPI_URL or STRAPI_API_TOKEN in environment');
}
this.baseUrl = baseUrl;
this.token = token;
}
async find(contentType: string, query?: string): Promise<unknown> {
const url = `${this.baseUrl}/api/${contentType}${query ? `?${query}` : ''}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Strapi request failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
async findOne(contentType: string, documentId: string, query?: string): Promise<unknown> {
const url = `${this.baseUrl}/api/${contentType}/${documentId}${query ? `?${query}` : ''}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`Strapi request failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
async create(contentType: string, data: Record<string, unknown>): Promise<unknown> {
const response = await fetch(`${this.baseUrl}/api/${contentType}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ data }),
});
if (!response.ok) {
throw new Error(`Strapi create failed: ${response.status} ${response.statusText}`);
}
return response.json();
}
}
export const strapiPlugin = new Elysia({ name: 'strapi' })
.decorate('strapi', new StrapiService());This service uses Bun's global fetch. No additional HTTP client libraries are needed. The .decorate() method from Elysia's context extension pattern makes the service available in every route handler through dependency injection.
Note the documentId parameter in findOne. Strapi v5 replaced numeric id fields with string-based documentId values, so all single-document lookups use this identifier.
Step 7: Define TypeScript Types for Strapi v5 Responses
Strapi v5 uses a flattened response format. Fields appear directly on the data object without an attributes wrapper. Create src/types/strapi.ts to reflect this:
import { t } from 'elysia';
// Strapi v5 collection response shape
export const StrapiCollectionResponse = t.Object({
data: t.Array(
t.Object({
documentId: t.String(),
title: t.String(),
slug: t.String(),
content: t.Nullable(t.String()),
createdAt: t.String(),
updatedAt: t.String(),
publishedAt: t.Nullable(t.String()),
})
),
meta: t.Object({
pagination: t.Object({
page: t.Number(),
pageSize: t.Number(),
pageCount: t.Number(),
total: t.Number(),
}),
}),
});
// Strapi v5 single item response shape
export const StrapiSingleResponse = t.Object({
data: t.Object({
documentId: t.String(),
title: t.String(),
slug: t.String(),
content: t.Nullable(t.String()),
createdAt: t.String(),
updatedAt: t.String(),
publishedAt: t.Nullable(t.String()),
}),
meta: t.Object({}),
});These TypeBox schemas serve double duty: runtime validation of Strapi responses and compile-time TypeScript type inference. If Strapi returns unexpected data, Elysia's validation layer catches it with a 422 response instead of passing malformed data to your clients.
Step 8: Create Elysia Routes That Consume Strapi Content
Wire everything together in src/index.ts:
import { Elysia, t } from 'elysia';
import { cors } from '@elysiajs/cors';
import { strapiPlugin } from './services/strapi';
import { StrapiCollectionResponse, StrapiSingleResponse } from './types/strapi';
const app = new Elysia()
.use(cors({
origin: ['http://localhost:5173'],
credentials: true,
}))
.use(strapiPlugin)
.get('/api/articles', async ({ strapi }) => {
return await strapi.find('articles', 'populate=*&sort=createdAt:desc');
}, {
response: StrapiCollectionResponse,
detail: {
tags: ['Articles'],
summary: 'List all published articles from Strapi',
},
})
.get('/api/articles/:documentId', async ({ strapi, params }) => {
return await strapi.findOne('articles', params.documentId, 'populate=*');
}, {
params: t.Object({
documentId: t.String(),
}),
response: StrapiSingleResponse,
detail: {
tags: ['Articles'],
summary: 'Get a single article by documentId',
},
})
.onError(({ error, set }) => {
if (error.message.includes('Strapi request failed')) {
set.status = 502;
return { error: 'Content service unavailable', detail: error.message };
}
set.status = 500;
return { error: 'Internal server error' };
})
.listen(Bun.env.PORT ? parseInt(Bun.env.PORT) : 3000);
console.log(`Elysia running at http://localhost:${app.server?.port}`);The onError lifecycle hook catches Strapi failures and returns a 502 status, keeping your API's error responses consistent. The lifecycle documentation covers additional error codes you can handle.
Step 9: Test the Integration
Make sure both services are running: Strapi on port 1337, Elysia on port 3000. Then test with curl:
# List all articles
curl http://localhost:3000/api/articles
# Get a single article (replace with an actual documentId from your Strapi data)
curl http://localhost:3000/api/articles/abc123xyzYou should see Strapi's v5 flattened response structure with documentId fields and data at the root level, with no nested attributes object.
Project Example: Content-Powered Blog API with Filtering and Search
Let's extend the basic integration into a practical blog API that supports filtered queries, relation population, and paginated responses. This is the kind of API a frontend team would actually consume.
Project Setup
Start from the Elysia project created above. Add the OpenAPI plugin for auto-generated documentation:
bun add @elysiajs/openapiExtend the Strapi Service with Query Building
Strapi v5's filter syntax uses bracket notation for complex queries. Add a query builder to src/services/strapi.ts:
export function buildStrapiQuery(options: {
filters?: Record<string, { operator: string; value: string }>;
sort?: string;
page?: number;
pageSize?: number;
populate?: string | string[];
fields?: string[];
}): string {
const params = new URLSearchParams();
if (options.filters) {
for (const [field, { operator, value }] of Object.entries(options.filters)) {
params.append(`filters[${field}][${operator}]`, value);
}
}
if (options.sort) {
params.append('sort', options.sort);
}
if (options.page) {
params.append('pagination[page]', options.page.toString());
}
if (options.pageSize) {
params.append('pagination[pageSize]', options.pageSize.toString());
}
if (options.populate) {
if (Array.isArray(options.populate)) {
options.populate.forEach((rel, i) => {
params.append(`populate[${i}]`, rel);
});
} else {
params.append('populate', options.populate);
}
}
if (options.fields) {
options.fields.forEach((field, i) => {
params.append(`fields[${i}]`, field);
});
}
return params.toString();
}The population documentation explains that relations aren't included in Strapi v5 responses by default. You must explicitly request them.
Build the Blog API Routes
Create src/routes/blog.ts with filtered listing, search, and detail endpoints:
import { Elysia, t } from 'elysia';
import { strapiPlugin, buildStrapiQuery } from '../services/strapi';
const ArticleListSchema = t.Object({
data: t.Array(
t.Object({
documentId: t.String(),
title: t.String(),
slug: t.String(),
content: t.Nullable(t.String()),
createdAt: t.String(),
updatedAt: t.String(),
publishedAt: t.Nullable(t.String()),
})
),
meta: t.Object({
pagination: t.Object({
page: t.Number(),
pageSize: t.Number(),
pageCount: t.Number(),
total: t.Number(),
}),
}),
});
export const blogRoutes = new Elysia({ prefix: '/api/blog' })
.use(strapiPlugin)
.get('/posts', async ({ strapi, query }) => {
const strapiQuery = buildStrapiQuery({
sort: 'createdAt:desc',
page: query.page ? parseInt(query.page) : 1,
pageSize: query.pageSize ? parseInt(query.pageSize) : 10,
populate: '*',
});
return await strapi.find('articles', strapiQuery);
}, {
query: t.Object({
page: t.Optional(t.String()),
pageSize: t.Optional(t.String()),
}),
response: ArticleListSchema,
detail: {
tags: ['Blog'],
summary: 'List blog posts with pagination',
},
})
.get('/posts/search', async ({ strapi, query }) => {
const strapiQuery = buildStrapiQuery({
filters: {
title: { operator: '$contains', value: query.q },
},
sort: 'createdAt:desc',
pageSize: 20,
populate: '*',
});
return await strapi.find('articles', strapiQuery);
}, {
query: t.Object({
q: t.String({ minLength: 2 }),
}),
response: ArticleListSchema,
detail: {
tags: ['Blog'],
summary: 'Search blog posts by title',
},
})
.get('/posts/:slug', async ({ strapi, params }) => {
const strapiQuery = buildStrapiQuery({
filters: {
slug: { operator: '$eq', value: params.slug },
},
populate: '*',
});
const result = await strapi.find('articles', strapiQuery) as {
data: Array<Record<string, unknown>>;
};
if (!result.data || result.data.length === 0) {
throw new Error('Article not found');
}
return { data: result.data[0], meta: {} };
}, {
params: t.Object({
slug: t.String(),
}),
detail: {
tags: ['Blog'],
summary: 'Get a blog post by slug',
},
});Wire Routes into the Main Application
Update src/index.ts to mount the blog routes and add OpenAPI documentation:
import { Elysia } from 'elysia';
import { cors } from '@elysiajs/cors';
import { openapi } from '@elysiajs/openapi';
import { blogRoutes } from './routes/blog';
const app = new Elysia()
.use(cors({
origin: ['http://localhost:5173'],
credentials: true,
}))
.use(openapi({
documentation: {
info: {
title: 'Blog Content API',
version: '1.0.0',
description: 'Elysia API layer backed by Strapi v5',
},
tags: [
{ name: 'Blog', description: 'Blog post operations' },
],
},
}))
.use(blogRoutes)
.onError(({ error, set }) => {
if (error.message === 'Article not found') {
set.status = 404;
return { error: 'Article not found' };
}
if (error.message.includes('Strapi request failed')) {
set.status = 502;
return { error: 'Content service unavailable' };
}
set.status = 500;
return { error: 'Internal server error' };
})
.listen(Bun.env.PORT ? parseInt(Bun.env.PORT) : 3000);
console.log(`Blog API running at http://localhost:${app.server?.port}`);
console.log(`API docs at http://localhost:${app.server?.port}/openapi`);The OpenAPI plugin auto-generates interactive documentation from your route definitions and validation schemas. Visit /openapi to explore your API endpoints in the browser.
Test the Blog API
# Paginated listing
curl "http://localhost:3000/api/blog/posts?page=1&pageSize=5"
# Search by title
curl "http://localhost:3000/api/blog/posts/search?q=typescript"
# Get by slug
curl "http://localhost:3000/api/blog/posts/getting-started-with-elysia"The project structure at this point:
elysia-api/
├── src/
│ ├── index.ts
│ ├── routes/
│ │ └── blog.ts
│ ├── services/
│ │ └── strapi.ts
│ └── types/
│ └── strapi.ts
├── env.d.ts
├── .env
├── package.json
└── tsconfig.jsonThis architecture keeps Strapi communication isolated in the service layer, making it straightforward to add new content types. Need a "categories" endpoint? Add a new route file, define its response schema, and in the controller use the appropriate Strapi API (for example, strapi.entityService.findMany('api::category.category', ...) in v5 or rely on the autogenerated GET /api/categories REST endpoint). The service layer handles authentication and error mapping consistently across every endpoint.
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 Elysia 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.