Lit

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 logo

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.

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 attributes wrapper 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 higher

Step 1: Create the Strapi v5 Project

Start by scaffolding a new Strapi project:

npx create-strapi@latest my-strapi-backend
cd my-strapi-backend

The CLI walks you through configuration options. For local development, the default SQLite database works fine.

Launch the development server:

npm run develop

Strapi 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 NameTypeNotes
titleTextShort text
slugUIDAttached to title
contentRich textArticle body
excerptTextLong text
publishedAtDateTimeManaged 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/articles

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

Install the @lit/task package for declarative data fetching:

npm install @lit/task

Your 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_here

Vite 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 dev

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

Frequently Answered Questions

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.