Hi,
I’ve made this guide after struggling to find how to serve a custom card from an integration as it was not documented.
This way
- card always stays in sync with the integration
- better user experience, no need to install separately card & integration
- card is automatically registered and resource added to lovelace
Provided snippets are cleaned code from this integration
This guide explains how to automatically embed a custom Lovelace card within a Home Assistant integration, without requiring users to manually add the JavaScript resource.
Overview
The mechanism relies on three pillars:
- Dependency declaration in
manifest.json - Static HTTP path registration to serve JavaScript files
- Automatic resource addition to Lovelace resources (storage mode)
Step 1: Directory Structure
custom_components/
└── your_integration/
├── __init__.py # Integration entry point
├── manifest.json # Integration metadata
├── const.py # Constants (URL_BASE, JSMODULES)
├── services.yaml # Service definitions (optional)
└── frontend/
├── __init__.py # JSModuleRegistration
└── your-card.js # Compiled JavaScript card file
Step 2: Configure manifest.json
The manifest.json must declare frontend and http dependencies:
{
"domain": "your_integration",
"name": "Your Integration",
"version": "1.0.0",
"dependencies": [
"frontend",
"http"
],
"config_flow": true,
"iot_class": "cloud_polling"
}
Important
Without thefrontendandhttpdependencies, resource registration will fail.
Step 3: Define Constants
In const.py, define the base URL and JavaScript modules list:
from pathlib import Path
import json
from typing import Final
# Read version from manifest.json
MANIFEST_PATH = Path(__file__).parent / "manifest.json"
with open(MANIFEST_PATH, encoding="utf-8") as f:
INTEGRATION_VERSION: Final[str] = json.load(f).get("version", "0.0.0")
DOMAIN: Final[str] = "your_integration"
# Base URL for frontend resources
URL_BASE: Final[str] = "/your-integration"
# List of JavaScript modules to register
JSMODULES: Final[list[dict[str, str]]] = [
{
"name": "Your Card",
"filename": "your-card.js",
"version": INTEGRATION_VERSION,
},
# Add editor if needed
{
"name": "Your Card Editor",
"filename": "your-card-editor.js",
"version": INTEGRATION_VERSION,
},
]
Step 4: Create JSModuleRegistration Class
Create frontend/__init__.py with the registration class:
"""JavaScript module registration."""
import logging
from pathlib import Path
from typing import Any
from homeassistant.components.http import StaticPathConfig
from homeassistant.core import HomeAssistant
from homeassistant.helpers.event import async_call_later
from ..const import JSMODULES, URL_BASE, INTEGRATION_VERSION
_LOGGER = logging.getLogger(__name__)
class JSModuleRegistration:
"""Registers JavaScript modules in Home Assistant."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the registrar."""
self.hass = hass
self.lovelace = self.hass.data.get("lovelace")
async def async_register(self) -> None:
"""Register frontend resources."""
await self._async_register_path()
# Only register modules if Lovelace is in storage mode
if self.lovelace.mode == "storage":
await self._async_wait_for_lovelace_resources()
async def _async_register_path(self) -> None:
"""Register the static HTTP path."""
try:
await self.hass.http.async_register_static_paths(
[StaticPathConfig(URL_BASE, Path(__file__).parent, False)]
)
_LOGGER.debug("Path registered: %s -> %s", URL_BASE, Path(__file__).parent)
except RuntimeError:
_LOGGER.debug("Path already registered: %s", URL_BASE)
async def _async_wait_for_lovelace_resources(self) -> None:
"""Wait for Lovelace resources to load."""
async def _check_loaded(_now: Any) -> None:
if self.lovelace.resources.loaded:
await self._async_register_modules()
else:
_LOGGER.debug("Lovelace resources not loaded, retrying in 5s")
async_call_later(self.hass, 5, _check_loaded)
await _check_loaded(0)
async def _async_register_modules(self) -> None:
"""Register or update JavaScript modules."""
_LOGGER.debug("Installing JavaScript modules")
# Get existing resources from this integration
existing_resources = [
r for r in self.lovelace.resources.async_items()
if r["url"].startswith(URL_BASE)
]
for module in JSMODULES:
url = f"{URL_BASE}/{module['filename']}"
registered = False
for resource in existing_resources:
if self._get_path(resource["url"]) == url:
registered = True
# Check if update needed
if self._get_version(resource["url"]) != module["version"]:
_LOGGER.info(
"Updating %s to version %s",
module["name"], module["version"]
)
await self.lovelace.resources.async_update_item(
resource["id"],
{
"res_type": "module",
"url": f"{url}?v={module['version']}",
},
)
break
if not registered:
_LOGGER.info(
"Registering %s version %s",
module["name"], module["version"]
)
await self.lovelace.resources.async_create_item(
{
"res_type": "module",
"url": f"{url}?v={module['version']}",
}
)
def _get_path(self, url: str) -> str:
"""Extract path without parameters."""
return url.split("?")[0]
def _get_version(self, url: str) -> str:
"""Extract version from URL."""
parts = url.split("?")
if len(parts) > 1 and parts[1].startswith("v="):
return parts[1].replace("v=", "")
return "0"
async def async_unregister(self) -> None:
"""Remove Lovelace resources from this integration."""
if self.lovelace.mode == "storage":
for module in JSMODULES:
url = f"{URL_BASE}/{module['filename']}"
resources = [
r for r in self.lovelace.resources.async_items()
if r["url"].startswith(url)
]
for resource in resources:
await self.lovelace.resources.async_delete_item(resource["id"])
Step 5: Call Registration in async_setup
In the main __init__.py, call JSModuleRegistration:
from homeassistant.core import (
HomeAssistant,
CoreState,
EVENT_HOMEASSISTANT_STARTED,
)
from homeassistant.components import websocket_api
from homeassistant.helpers import config_validation as cv
import voluptuous as vol
from .frontend import JSModuleRegistration
from .const import DOMAIN, INTEGRATION_VERSION
async def async_register_frontend(hass: HomeAssistant) -> None:
"""Register frontend modules after HA startup."""
module_register = JSModuleRegistration(hass)
await module_register.async_register()
@websocket_api.websocket_command(
{
vol.Required("type"): f"{DOMAIN}/version",
}
)
@websocket_api.async_response
async def websocket_get_version(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
) -> None:
"""Handle version request from frontend."""
connection.send_result(
msg["id"],
{"version": INTEGRATION_VERSION},
)
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the component."""
# Register websocket command for version checking
websocket_api.async_register_command(hass, websocket_get_version)
async def _setup_frontend(_event=None) -> None:
await async_register_frontend(hass)
# If HA is already running, register immediately
if hass.state == CoreState.running:
await _setup_frontend()
else:
# Otherwise, wait for STARTED event
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _setup_frontend)
return True
WARNING
Registration must happen inasync_setup, notasync_setup_entry.
This ensures registration occurs once per integration, not per config entry.
Step 6: Create the JavaScript Card
The card must register as a custom element and declare itself in window.customCards:
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
// Version embedded during build (replace with your actual version)
const CARD_VERSION = '1.0.0';
@customElement('your-card')
export class YourCard extends LitElement {
@property({ attribute: false }) hass;
@property({ attribute: false }) config;
private backendVersion = null;
private versionCheckDone = false;
static getConfigElement() {
return document.createElement('your-card-editor');
}
static getStubConfig() {
return { entity: '' };
}
setConfig(config) {
this.config = config;
}
getCardSize() {
return 3;
}
connectedCallback() {
super.connectedCallback();
this.checkVersion();
}
async checkVersion() {
if (this.versionCheckDone) return;
try {
const result = await this.hass.connection.sendMessagePromise({
type: 'your_integration/version',
});
this.backendVersion = result.version;
this.versionCheckDone = true;
if (this.backendVersion !== CARD_VERSION) {
this.showVersionMismatch();
}
} catch (err) {
console.error('Failed to check version:', err);
}
}
showVersionMismatch() {
// Show toast notification with reload action
// Using hass-notification event instead of persistent_notification
// because toast only appears in current session and gets immediate attention
const message = `Your Integration version mismatch detected! Backend: ${this.backendVersion} | Frontend: ${CARD_VERSION}`;
this.dispatchEvent(
new CustomEvent('hass-notification', {
detail: {
message: message,
duration: -1, // Persistent until dismissed
dismissable: true,
action: {
text: 'Reload',
action: this.handleReload,
},
},
bubbles: true,
composed: true,
})
);
}
// Arrow function to preserve 'this' context when used as action handler
handleReload = () => {
// Clear application cache before reload
if ('caches' in window) {
caches.keys().then((names) => {
names.forEach((name) => {
caches.delete(name);
});
}).then(() => {
window.location.reload();
});
} else {
window.location.reload();
}
}
render() {
if (!this.hass || !this.config) {
return html``;
}
return html`<ha-card header="My Card">...</ha-card>`;
}
static styles = css`
ha-card { padding: 16px; }
`;
}
// Registration to appear in card picker
window.customCards = window.customCards || [];
window.customCards.push({
type: 'your-card',
name: 'Your Card',
preview: true,
description: 'Description of your card',
});
Step 7: Build and Deployment
Webpack Configuration (example)
// webpack.config.cjs
const path = require('path');
const webpack = require('webpack');
const package = require('./package.json');
module.exports = {
mode: 'production',
entry: {
'your-card': './src/your-card.ts',
'your-card-editor': './src/your-card-editor.ts',
},
output: {
filename: '[name].js',
path: path.resolve(__dirname, '../custom_components/your_integration/frontend'),
clean: {
keep: /__init__\.py$/, // Keep the Python file
},
},
resolve: {
extensions: ['.js', '.ts'],
},
module: {
rules: [
{
test: /\.ts$/,
exclude: /node_modules/,
use: 'ts-loader',
},
],
},
plugins: [
// Inject version at build time
new webpack.DefinePlugin({
CARD_VERSION: JSON.stringify(package.version),
}),
],
};
Step 8: Optional Reload Service
For better user experience, you can add a service to clear cache and reload. Create services.yaml:
reload_frontend:
name: Reload Frontend
description: Clears application cache and reloads the page
fields: {}
And add the service handler in __init__.py:
from homeassistant.helpers import service
async def async_reload_frontend(call):
"""Handle reload frontend service call."""
# This service is mainly documented for users
# The actual reload happens on frontend side
_LOGGER.info("Frontend reload requested via service")
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the component."""
# ... previous code ...
# Register reload service
hass.services.async_register(
DOMAIN,
"reload_frontend",
async_reload_frontend,
)
return True
Version Management and Application Cache
The Application Cache Problem
CRITICAL ISSUE
The?v=X.X.Xparameter in resource URLs does NOT solve all caching issues!
Why versioned URLs are not enough:
JavaScript module URLs are embedded in cached page files (application cache). When you update your integration:
- The backend updates the resource URL with a new version
- BUT the cached page still references the OLD URL
- Users get version mismatches depending on their start page
Where this manifests:
- Desktop browsers: Hard refresh (Ctrl+Shift+R) clears cache, masking the issue
- Companion Apps (iOS/Android): Cache persists, causing consistent problems
- Different start pages: Some pages cached, others not, leading to inconsistent behavior
Recommended Solution: Version Checking
Implement a version check system inspired by Browser Mod:
- Backend exposes version via WebSocket command
- Frontend checks version on card load
- Mismatch detection triggers user notification
- Clear cache + reload button provided to user
Key benefits:
- Users are informed of version mismatches
- One-click resolution (clear cache + reload)
- Works reliably on all platforms
- Prevents mysterious bugs and support issues
Implementation Checklist
- Backend WebSocket command returns integration version
- Frontend card checks version on
connectedCallback() - Version mismatch shows toast notification (hass-notification event)
- Reload button clears
cachesAPI before reload - Version constant embedded during build (webpack DefinePlugin)
- Optional: Reload service for power users
Summary of Key Elements
| Element | File | Purpose |
|---|---|---|
| Dependencies | manifest.json |
"dependencies": ["frontend", "http"] |
| Base URL | const.py |
Defines /your-integration |
| Module list | const.py |
JSMODULES with name, filename, version |
| Version export | const.py |
INTEGRATION_VERSION for WebSocket |
| Static path | frontend/__init__.py |
async_register_static_paths |
| Lovelace resource | frontend/__init__.py |
lovelace.resources.async_create_item |
| WebSocket handler | __init__.py |
Version checking endpoint |
| Trigger | __init__.py |
In async_setup, listen for EVENT_HOMEASSISTANT_STARTED |
| Custom Element | your-card.js |
@customElement('your-card') |
| Version check | your-card.js |
checkVersion() on card load |
| Cache clear | your-card.js |
handleReload() clears caches API |
| Card declaration | your-card.js |
window.customCards.push({...}) |
YAML Mode vs Storage Mode
[!NOTE]
Automatic resource registration only works in storage mode (Lovelace’s default mode).In YAML mode, users must manually add the resource in
ui-lovelace.yaml:resources: - url: /your-integration/your-card.js type: module
The static path (/your-integration/your-card.js) is registered in all cases, so the file is always accessible.
Troubleshooting
Users report “card not working after update”
Cause: Application cache contains old module URL
Solution:
- Check if version mismatch notification appears
- Click reload button in notification
- If notification doesn’t appear, manually clear cache:
- Desktop: Hard refresh (Ctrl+Shift+R / Cmd+Shift+R)
- Mobile app: Clear app cache or force-stop app
Version check not working
Verify:
- WebSocket command registered in
async_setup - Frontend calls correct command type (
your_integration/version) - Version constant matches between backend and build output
- Check browser console for errors
Cache not clearing on reload
Issue: caches API requires HTTPS or localhost
Workaround: Add fallback to standard reload if caches unavailable
Unregistration (Optional)
To clean up resources when uninstalling the integration, call async_unregister in an appropriate hook (if available in your flow).
Credits
- Original guide structure from marees_france integration
- Version management approach inspired by Browser Mod
- Community feedback on application cache behavior
