Elysia

This guide walks you through integrating Elysia, a TypeScript-first Bun framework, with Strapi v5, a headless CMS (Content Management System). You'll build a type-safe API layer with authentication, validation, and practical code examples for production use

Elysia logo

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.

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, no node-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:

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-project

The 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 develop

This 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 NameTypeDetails
titleTextShort text, required
slugUIDAttached to title field
contentRich textMain article body
publishedAtDateTimeAuto-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/cors

Create 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=3000

Bun 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/abc123xyz

You should see Strapi's v5 flattened response structure with documentId fields and data at the root level, with no nested attributes object.

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/openapi

Extend 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.json

This 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.

Frequently Answered Questions

No. Strapi v5 depends on Koa.js, specific database drivers, and native modules like Sharp that aren't fully compatible with Bun's runtime. The Strapi feedback portal contains a feature request noting Bun's current compatibility issues with Strapi's Koa.js-based stack, but it does not state that there is no official Bun support. The recommended architecture runs Strapi on Node.js v20, v22, or v24 while Elysia runs on Bun separately. This means managing two separate processes during development: one running npm run develop for Strapi on Node.js and another running bun run for your Elysia application.

Strapi v5 introduced a flattened response format that removes the nested attributes wrapper from v4. Fields like title and content now appear directly on the data object. Additionally, the numeric id field was replaced with a string-based documentId for document identification. Your Elysia TypeBox schemas must reference documentId as a string type rather than id as a number, and field accessors change from data.attributes.title to data.title.

Elysia doesn't include a dedicated HTTP client for external APIs like Strapi. Eden Treaty, Elysia's type-safe client, works exclusively with Elysia servers. For Strapi integration, you use Bun's native global fetch API directly. The recommended pattern is building a service layer class that encapsulates Strapi requests, handles authentication headers, and manages errors, then injecting it into routes via Elysia's .decorate() method. This centralizes configuration and token management in one place.

Strapi v5 supports API token authentication for external applications. Generate a token in the Strapi Admin Panel under Settings → API Tokens, then include it as a Bearer token in your fetch requests: Authorization: Bearer YOUR_TOKEN. Store the token in environment variables, not source code. Beyond read-only tokens, Strapi supports full-access and custom token types, letting you scope permissions per Collection Type. For production, set expiration dates and rotate tokens.

Strapi v5's REST API supports filter operators like $eq, $contains, $in, $lt, and $gt using bracket notation: filters[field][$operator]=value. Sorting uses sort=field:asc syntax. Pagination accepts pagination[page] and pagination[pageSize] parameters. Relations require explicit population with populate=* for first-level or populate[relation][populate][nested]=* for deep relations. You can chain multiple filter parameters in a single request to narrow results. Field selection uses fields[0]=title syntax.