Learn how to integrate Lit web components with Strapi v5 to build fast, standards-based content-driven applications. This guide walks through project setup, API token configuration, reactive data fetching patterns, and a complete hands-on blog project example

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.
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.
attributes wrapper to unwrap, which means less parsing logic in your Lit components.This section covers creating both projects from scratch, connecting them through Strapi's REST API, and fetching content into Lit components.
Before starting, confirm you have these installed:
Verify your environment:
node --version # Should output v18.x or higher
npm --version # Should output 9.x or higherStart 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.
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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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>`,
})}
`;
}
}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.
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.
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.
Yes. Install the @strapi/plugin-graphql package in your Strapi project and enable it in your plugin configuration. From your Lit components, send GraphQL queries using the native Fetch API to Strapi's /graphql endpoint. Wrap the request in an @lit/task controller for automatic state management. One limitation to know: media uploads require the REST API even with GraphQL enabled, so plan your file handling accordingly.
Strapi v5 uses a flattened response format where content fields sit at the root level of each data object. The nested attributes wrapper from v4 is gone. Documents are identified by a string documentId instead of a numeric id. This simplifies data binding in Lit components because you access article.title directly rather than article.attributes.title. Update any existing parsing logic when migrating.
The @lit/task reactive controller is the officially recommended approach. It provides declarative rendering with automatic pending, complete, and error states. It re-fetches automatically when dependency values change, such as an article slug property updating. For shared state across multiple components, custom reactive controllers or the experimental @lit-labs/signals package offer reusable alternatives. Avoid fetching data in the constructor or render method to prevent errors.
No dedicated client-side package is required. Strapi v5 exposes standard REST endpoints at /api/*, so the native browser Fetch API handles communication directly. Add an Authorization header with your API token for authenticated requests. You can optionally use Axios or Strapi's @strapi/sdk-js package for convenience features like interceptors and typed responses, but neither is mandatory for the integration to work.
Configure explicit allowed origins in Strapi's config/middlewares.js file under the strapi::cors middleware. Specify your Lit development URL (for example, http://localhost:5173) and production domain as an array. Never use a wildcard origin (*) with credentials: true — browsers reject that combination per the CORS specification. Include Content-Type and Authorization in the allowed headers to support authenticated API requests from your components.