These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Lit?
Lit is a lightweight library for building web components; a more recent stable release after 3.3.2 is version 4.2.1. It evolved from the earlier LitElement and lit-html projects into a single, cohesive package.
At its core, Lit provides three things: a reactive property system that triggers re-renders when data changes, a declarative templating engine using tagged template literals, and scoped styles through Shadow DOM encapsulation.
Because Lit builds on W3C standards (Custom Elements, Shadow DOM, HTML Templates), the components you create work in any modern browser without polyfills. They're also compatible with React, Vue, Angular, or plain HTML. No framework adapter required.
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('greeting-card')
class GreetingCard extends LitElement {
static styles = css`
:host { display: block; padding: 16px; }
h2 { color: var(--theme-color, #333); }
`;
@property({ type: String }) name = 'World';
render() {
return html`<h2>Hello, ${this.name}!</h2>`;
}
}That's a fully functional, encapsulated web component in about 15 lines.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate Lit with Strapi
Pairing Lit's lightweight component model with Strapi's headless CMS creates a clean separation between content management and presentation, without the overhead of a full frontend framework.
- Minimal bundle impact. Lit adds roughly 5KB to your application, leaving bandwidth for the media-rich content assets your CMS serves. Compare that to React (~40KB) or Angular (80KB+), and the difference compounds on content-heavy sites.
- Standards-based portability. Lit components run anywhere Custom Elements are supported. Build a component library for your Strapi-powered blog, then reuse those same components in a marketing site, internal tool, or mobile web app without rewriting anything.
- Efficient reactive updates. Lit updates only the specific DOM expressions that change, not entire component trees. For dynamic content like live feeds or personalized pages from Strapi's API, this keeps rendering fast.
- Clean API consumption. Strapi v5's flattened response format delivers content fields at the root level of the data object. No nested
attributeswrapper to unwrap, which means less parsing logic in your Lit components. - TypeScript alignment. Both Lit and Strapi v5 offer strong TypeScript support. Strapi can generate types from your content schemas, and Lit's decorators provide compile-time checking for your component properties.
- Independent scaling and deployment. Your Lit frontend and Strapi backend deploy separately. Update content without touching the frontend. Ship new components without redeploying the CMS.
How to Integrate Lit with Strapi
This section covers creating both projects from scratch, connecting them through Strapi's REST API, and fetching content into Lit components.
Prerequisites
Before starting, confirm you have these installed:
- Node.js 18+ (LTS recommended) — download here
- npm 9+ (ships with Node.js)
- A code editor with TypeScript support (VS Code recommended)
- Basic familiarity with TypeScript and REST APIs
Verify your environment:
node --version # Should output v18.x or higher
npm --version # Should output 9.x or higherStep 1: Create the Strapi v5 Project
Start by scaffolding a new Strapi project:
npx create-strapi@latest my-strapi-backend
cd my-strapi-backendThe CLI walks you through configuration options. For local development, the default SQLite database works fine.
Launch the development server:
npm run developStrapi opens the Admin Panel at http://localhost:1337/admin. Create your admin account on the first visit.
Step 2: Define a Content-Type in Strapi
Navigate to the Content-Type Builder in the Admin Panel and create a new Collection Type called Article with these fields:
| Field Name | Type | Notes |
|---|---|---|
| title | Text | Short text |
| slug | UID | Attached to title |
| content | Rich text | Article body |
| excerpt | Text | Long text |
| publishedAt | DateTime | Managed by Draft & Publish |
After saving, Strapi restarts automatically. Head to the Content Manager, create two or three sample articles, and publish them.
Step 3: Configure API Permissions and Tokens
Your Lit frontend needs access to the Article API. There are two approaches.
Option A: Public permissions (development)
Go to Settings → Users & Permissions → Roles → Public. Under the Article content-type, enable find and findOne, then save.
Option B: API Token (production-recommended)
Navigate to Settings → API Tokens → Create new API Token. Configure it:
- Name: Lit Frontend
- Token type: Read-only
- Duration: Choose based on your rotation policy
Copy the token immediately — Strapi only shows it once. Strapi automatically generates an API token salt and stores it as API_TOKEN_SALT in your .env, and you can optionally override it via the environment variable or apiToken.salt configuration.
Test the endpoint:
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
https://your-strapi-app.com/api/articlesThe response uses Strapi v5's flattened format:
{
"data": {
"documentId": "abc123",
"title": "Article Title",
"content": "Article content...",
"createdAt": "2024-03-15T10:00:00.000Z",
"updatedAt": "2024-03-15T10:00:00.000Z"
},
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 1
}
}
}Note that fields sit at the root level of each data object. No attributes nesting — that's a v5 change.
Step 4: Scaffold the Lit Project
In a separate terminal, create a Lit project with TypeScript using Vite:
npm create vite@latest my-lit-app -- --template lit-ts
cd my-lit-app
npm installInstall the @lit/task package for declarative data fetching:
npm install @lit/taskYour package.json dependencies should look like this:
{
"name": "my-lit-frontend",
"type": "module",
"dependencies": {
"lit": "^3.3.2",
"axios": "^1.13.5"
},
"devDependencies": {
"typescript": "^5.9.3",
"vite": "^4.3.1"
}
}Confirm your tsconfig.json includes these settings (critical for Lit's decorator syntax):
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"experimentalDecorators": true,
"useDefineForClassFields": false,
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}Two settings matter here: experimentalDecorators enables @property and @customElement decorators, and useDefineForClassFields set to false prevents conflicts with Lit's reactive property system.
Step 5: Configure Environment Variables
Create a .env file in your Lit project root:
VITE_STRAPI_API_URL=http://localhost:1337
VITE_STRAPI_API_TOKEN=your_api_token_hereVite exposes variables prefixed with VITE_ to your client code via import.meta.env. One important caveat: these values get compiled into the bundle and are visible to end users. For public read-only content, that's fine. For anything sensitive, route requests through a backend proxy instead.
Step 6: Create a Strapi API Service
Create src/services/strapi-api.ts to centralize API communication:
const API_URL = import.meta.env.VITE_STRAPI_API_URL;
const API_TOKEN = import.meta.env.VITE_STRAPI_API_TOKEN;
export interface StrapiResponse<T> {
data: T;
meta: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export interface Article {
documentId: string;
title: string;
slug: string;
content: string;
excerpt: string;
createdAt: string;
updatedAt: string;
}
async function strapiRequest<T>(
endpoint: string,
params?: Record<string, string>
): Promise<StrapiResponse<T>> {
const url = new URL(`/api${endpoint}`, API_URL);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (API_TOKEN) {
headers['Authorization'] = `Bearer ${API_TOKEN}`;
}
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData?.error?.message || `HTTP ${response.status}: Request failed`
);
}
return response.json();
}
export async function getArticles(
page = 1,
pageSize = 10
): Promise<StrapiResponse<Article[]>> {
return strapiRequest<Article[]>('/articles', {
'sort': 'createdAt:desc',
'pagination[page]': String(page),
'pagination[pageSize]': String(pageSize),
});
}
export async function getArticleBySlug(
slug: string
): Promise<StrapiResponse<Article[]>> {
return strapiRequest<Article[]>('/articles', {
'filters[slug][$eq]': slug,
'pagination[limit]': '1',
});
}This service handles authentication, query parameters, error parsing, and typed responses. The Article interface mirrors Strapi v5's flat response structure where documentId replaces the old numeric id.
Step 7: Build Lit Components That Consume Strapi Content
Create src/components/article-list.ts:
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
import { Task } from '@lit/task';
import { getArticles, type Article } from '../services/strapi-api.js';
@customElement('article-list')
export class ArticleList extends LitElement {
static styles = css`
:host {
display: block;
max-width: 800px;
margin: 0 auto;
padding: 24px;
font-family: system-ui, sans-serif;
}
.article-card {
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
transition: box-shadow 0.2s ease;
}
.article-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
h2 {
margin: 0 0 8px;
font-size: 1.4rem;
color: #1a1a2e;
}
.excerpt {
color: #555;
line-height: 1.6;
margin: 0 0 12px;
}
.meta {
font-size: 0.85rem;
color: #888;
}
.error {
padding: 16px;
background: #fee;
border: 1px solid #fcc;
border-radius: 8px;
color: #c33;
}
.loading {
text-align: center;
padding: 40px;
color: #888;
}
`;
private _articlesTask = new Task(this, {
task: async () => {
const response = await getArticles(1, 10);
return response.data;
},
args: () => [],
});
render() {
return html`
<h1>Latest Articles</h1>
${this._articlesTask.render({
pending: () => html`<div class="loading">Loading articles...</div>`,
complete: (articles: Article[]) =>
articles.length > 0
? html`
${articles.map(
(article) => html`
<div class="article-card">
<h2>${article.title}</h2>
<p class="excerpt">${article.excerpt}</p>
<span class="meta">
${new Date(article.createdAt).toLocaleDateString()}
</span>
</div>
`
)}
`
: html`<p>No articles found.</p>`,
error: (err: Error) =>
html`<div class="error">Failed to load articles: ${err.message}</div>`,
})}
`;
}
}The @lit/task controller manages loading, success, and error states automatically. When the task completes, Lit re-renders only the template expressions that changed.
Step 8: Configure CORS in Strapi
If your Lit dev server runs on a different port (it will — Vite defaults to 5173), Strapi needs to allow cross-origin requests. Update config/middlewares.js in your Strapi project:
module.exports = [
'strapi::logger',
'strapi::errors',
'strapi::security',
{
name: 'strapi::cors',
config: {
origin: ['http://localhost:5173', 'https://yourdomain.com'],
credentials: true,
headers: ['Content-Type', 'Authorization'],
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];This is where most teams learn the hard way: using a wildcard origin: '*' with credentials: true will fail. Browsers reject that combination per the CORS specification. Always specify explicit origins.
Step 9: Wire Everything Together
Update your index.html to use the article list component:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lit + Strapi Blog</title>
<script type="module" src="/src/components/article-list.ts"></script>
</head>
<body>
<article-list></article-list>
</body>
</html>Start both servers:
# Terminal 1 — Strapi
cd my-strapi-backend
npm run develop
# Terminal 2 — Lit
cd my-lit-frontend
npm run devOpen http://localhost:5173. Your Lit component fetches articles from Strapi's REST API and renders them with loading and error handling built in.
Project Example: Article Viewer with Dynamic Routing
Let's extend the integration into something more practical — a blog with an article list and individual article pages. This demonstrates how Lit and Strapi work together for a real content application.
Setting Up the Article Detail Component
Create src/components/article-detail.ts:
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { Task } from '@lit/task';
import { getArticleBySlug, type Article } from '../services/strapi-api.js';
@customElement('article-detail')
export class ArticleDetail extends LitElement {
static styles = css`
:host {
display: block;
max-width: 720px;
margin: 0 auto;
padding: 24px;
font-family: system-ui, sans-serif;
}
h1 {
font-size: 2rem;
color: #1a1a2e;
margin-bottom: 8px;
}
.meta {
color: #888;
font-size: 0.9rem;
margin-bottom: 24px;
}
.content {
line-height: 1.8;
color: #333;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #4945ff;
text-decoration: none;
cursor: pointer;
}
.error {
padding: 16px;
background: #fee;
border: 1px solid #fcc;
border-radius: 8px;
color: #c33;
}
`;
@property({ type: String }) slug = '';
private _articleTask = new Task(this, {
task: async ([slug]: [string]) => {
if (!slug) throw new Error('No article slug provided');
const response = await getArticleBySlug(slug);
if (!response.data || response.data.length === 0) {
throw new Error('Article not found');
}
return response.data[0];
},
args: () => [this.slug] as [string],
});
private _handleBack() {
this.dispatchEvent(
new CustomEvent('navigate-back', { bubbles: true, composed: true })
);
}
render() {
return html`
<a class="back-link" @click=${this._handleBack}>← Back to articles</a>
${this._articleTask.render({
pending: () => html`<p>Loading article...</p>`,
complete: (article: Article) => html`
<article>
<h1>${article.title}</h1>
<div class="meta">
Published ${new Date(article.createdAt).toLocaleDateString()}
</div>
<div class="content">${article.content}</div>
</article>
`,
error: (err: Error) =>
html`<div class="error">${err.message}</div>`,
})}
`;
}
}Handling Strapi Dynamic Zones
If your Strapi content types use Dynamic Zones for composable page sections, Lit handles them cleanly with a component switch pattern:
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
interface DynamicComponent {
__component: string;
[key: string]: unknown;
}
@customElement('dynamic-renderer')
export class DynamicRenderer extends LitElement {
@property({ type: Array }) zones: DynamicComponent[] = [];
render() {
return html`
${this.zones.map((component) => {
switch (component.__component) {
case 'sections.hero':
return html`<hero-section .data=${component}></hero-section>`;
case 'sections.rich-text':
return html`<rich-text-block .data=${component}></rich-text-block>`;
case 'sections.image-gallery':
return html`<image-gallery .data=${component}></image-gallery>`;
default:
return html``;
}
})}
`;
}
}Content editors can compose pages freely in Strapi's Content-Type Builder, and the Lit frontend renders whatever component arrangement they define.
Final index.html
Update your entry point to use the app shell:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lit + Strapi Blog</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; }
</style>
<script type="module" src="/src/components/blog-app.ts"></script>
</head>
<body>
<blog-app></blog-app>
</body>
</html>Run both servers and you have a working blog — article list, detail views, and error handling — pulling live content from Strapi with a frontend that ships around 5KB of framework code.
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 Lit 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.