Skip to content
ToolShedby Wasim Shaikh

Creating Your First Custom Element: A Step-by-Step Tutorial (React Weather Card)

Wasim · 8 Jul 2026

What You'll Build

In this tutorial, you'll build a Weather Card custom element — a standard web component, written in React 18 and styled with Clay UI, that fetches live weather for a configurable city and renders it inside a Liferay page. By the end, you'll be able to drop three instances of the same component onto a page — one for Honolulu, one for Fairbanks, one for New York — each showing different data from the same deployed code.

This tutorial pulls together everything covered in the earlier articles in this series: globalCSS and globalJS for the deployment pattern, jsImportMapsEntry for sharing React across the component, and now a full customElement client extension to tie it all together.

Reading time: 15-17 minutes · Difficulty: Intermediate · Audience: Frontend Developers, React Developers


What a Custom Element Actually Is

A custom element is a standard web component — built to the W3C Custom Elements specification — packaged together with a client-extension.yaml descriptor, zipped, and deployed to Liferay. Because it's a standard web component under the hood, you can build one with any framework that compiles down to a custom element: React, Vue, Angular, Lit, or plain JavaScript. Liferay doesn't care which framework you used — it only cares that the output registers a valid custom HTML element.


Prerequisites

  • Node.js and npm installed
  • Liferay Workspace set up locally
  • Java installed (per your Liferay version's compatibility matrix)
  • Blade CLI installed
  • A running Liferay DXP instance to test against
  • Familiarity with React function components and hooks

Step 1: Scaffold the Project

Inside your Liferay Workspace's client-extensions/ folder:

cd client-extensions
mkdir weather-card-custom-element
cd weather-card-custom-element
npm create vite@latest . -- --template react

This gives you a standard Vite + React starting point. You'll now adapt it into a custom element rather than a typical single-page app.

Delete the boilerplate you don't need — the default App.css, the sample counter logic, and any starter assets Vite scaffolds that aren't relevant to a weather widget.


Step 2: Pin React to Liferay's Bundled Version

Liferay ships with React 18. Vite's default template often scaffolds against the latest major version, which may be newer. Open package.json and make sure both dependencies and devDependencies are pinned to React 18:

{
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0"
  }
}

This matters because your component will run against the React runtime Liferay provides via its JS import map — not a bundled copy. A version mismatch between what you developed against and what Liferay actually serves at runtime is a common source of hard-to-debug errors.


Step 3: Add Clay UI Components

Install Clay's Card and Loading Indicator components, along with the Vite plugin needed to build a proper web component output:

npm install @clayui/card @clayui/loading-indicator
npm install --save-dev @originjs/vite-plugin-federation

Clay UI is Liferay's own design system — using it keeps your custom element visually consistent with the rest of the platform without hand-rolling your own card and spinner styles.


Step 4: Configure Vite for a Web Component Build

A custom element isn't a typical React SPA — it needs to build to a single entry point that registers a web component, and it needs to not bundle React, ReactDOM, or Clay, since those will be supplied by Liferay at runtime via JS import maps.

vite.config.js:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  base: '/o/client-extension/weather-card-custom-element/static/',
  build: {
    lib: {
      entry: 'src/index.jsx',
      formats: ['es'],
      fileName: () => 'index.js',
    },
    rollupOptions: {
      external: ['react', 'react-dom', '@clayui/card', '@clayui/loading-indicator'],
      output: {
        assetFileNames: 'index.[ext]',
      },
    },
  },
});

What this configuration does:

Setting Purpose
base Defines the path where the component's built assets will actually be served from once deployed
build.lib.entry Points to a single entry file rather than an index.html, since this isn't a standalone page
rollupOptions.external Tells Rollup (Vite's bundler) to not include React, ReactDOM, or Clay in the output — these are marked external and resolved at runtime instead

Declaring these as external is what makes the eventual jsImportMapsEntry pairing meaningful — without it, every custom element would re-bundle its own copy of React, exactly the problem the import map pattern exists to solve.


Step 5: Build the Component (and a Lesson in Web Component Lifecycle)

Here's where it's worth walking through the component in stages, because the mistakes along the way are as instructive as the final result.

Attempt 1: Hardcoded City (don't do this)

export default function WeatherCard() {
  const city = 'New York'; // hardcoded — bad
  // ...
}

This works, but changing the city means editing code and redeploying. Not acceptable for a component meant to be dropped into multiple page slots with different configurations.

Attempt 2: Configurable City, Missing Cleanup

import { useState, useEffect } from 'react';

export default function WeatherCard({ city }) {
  const [weather, setWeather] = useState(null);

  useEffect(() => {
    fetch(`/api/weather?city=${encodeURIComponent(city)}`)
      .then((r) => r.json())
      .then((data) => setWeather(data));
  }, [city]);

  return (
    <div className="weather-card">
      {weather && (
        <>
          <h3>{weather.city}</h3>
          <p>{weather.temp}°F</p>
          <p>{weather.condition}</p>
        </>
      )}
    </div>
  );
}

Better — the city is now a prop, not hardcoded. But this version is still incomplete: it has no cleanup logic for when the component is removed from the page. In a Liferay portal, users navigate between pages via client-side routing (Senna.js), which means components get connected and disconnected repeatedly without a full page reload. Without proper cleanup, in-flight fetches can resolve after the component is already gone, potentially causing memory leaks or React state update warnings.

Attempt 3: The Complete Version — With disconnectedCallback

Since a custom element is a full web component, it has its own lifecycle separate from React's — connectedCallback and disconnectedCallback. When you wrap a React app inside a custom element, you're responsible for wiring these up yourself:

src/weatherCard.jsx:

import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom/client';
import { Card } from '@clayui/card';
import ClayLoadingIndicator from '@clayui/loading-indicator';

function WeatherCardApp({ city }) {
  const [weather, setWeather] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetch(`/api/weather?city=${encodeURIComponent(city)}`)
      .then((r) => r.json())
      .then((data) => {
        if (!cancelled) {
          setWeather(data);
          setLoading(false);
        }
      })
      .catch(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [city]);

  if (loading) {
    return <ClayLoadingIndicator />;
  }

  return (
    <Card className="weather-card">
      <Card.Body>
        <Card.Description displayType="title">{weather?.city ?? city}</Card.Description>
        <Card.Description displayType="text">{weather?.temp}°F</Card.Description>
        <Card.Description displayType="subtitle">{weather?.condition}</Card.Description>
      </Card.Body>
    </Card>
  );
}

class WeatherCard extends HTMLElement {
  connectedCallback() {
    const city = this.getAttribute('city') || 'New York';
    this._root = ReactDOM.createRoot(this);
    this._root.render(<WeatherCardApp city={city} />);
  }

  disconnectedCallback() {
    if (this._root) {
      this._root.unmount();
      this._root = null;
    }
  }
}

export default WeatherCard;

Why disconnectedCallback matters here: without it, ReactDOM.createRoot keeps a React root alive after the element is removed from the DOM. In a portal where users navigate frequently via client-side routing rather than full page reloads, unmounted-but-not-cleaned-up React roots accumulate — a slow, hard-to-diagnose memory leak. Calling this._root.unmount() in disconnectedCallback ensures React tears down its internal state the moment the browser removes the element, exactly mirroring what would happen on a full page unload.

This is a common omission in AI-generated or quickly scaffolded custom element code — it's easy to get a component that works correctly on first render, and only discover the missing cleanup once you're navigating repeatedly between pages in a real portal session.


Step 6: Register the Custom Element

src/index.jsx:

import WeatherCard from './weatherCard';

if (!customElements.get('weather-card')) {
  customElements.define('weather-card', WeatherCard);
}

The customElements.get() check prevents a duplicate registration error if the module happens to be evaluated more than once — a defensive habit worth keeping in any custom element entry point.


Step 7: Configure client-extension.yaml

assemble:
  - from: dist
    into: static

weather-card-custom-element:
  name: Weather Card
  type: customElement
  htmlElementName: weather-card
  instanceable: true
  friendlyURLMapping: weather-card
  portletCategoryName: category.client-extensions
  urls:
    - index.js
  useESM: true

weather-card-import-map:
  name: Weather Card Import Map
  type: jsImportMapsEntry
  bareSpecifier: weather-card-custom-element
  url: index.js

Field breakdown for the customElement block:

Field Purpose
htmlElementName The custom HTML tag name — must match exactly what you passed to customElements.define()
instanceable true since you want multiple copies of this card on the same page, each with a different city
urls The JavaScript file(s) containing the custom element's registration code
useESM true, since the Vite build outputs an ES module
friendlyURLMapping A readable identifier used when referencing this extension elsewhere

The second block, weather-card-import-map, registers the custom element itself as a jsImportMapsEntry — the pattern covered in the previous article in this series. This isn't strictly required to make the Weather Card work as a page widget, but it makes the component easy to reference by import statement from inside a custom fragment later, rather than needing to hunt down and paste a generated resource URL every time the build hash changes.


Step 8: Build and Deploy

npm run build
blade gw deploy

Watch for the build successful message from the Vite build step, then tail your Liferay server logs:

tail -f LIFERAY_HOME/logs/catalina.out | grep weather-card

A STARTED message referencing your client extension's bundle confirms it deployed and initialized correctly.


Step 9: Add the Component to a Page

  1. Edit a page in your Liferay instance — for this example, one with a grid containing three columns works well.
  2. Open the widget/fragment panel and locate Weather Card under your Client Extensions category.
  3. Drag it into each of the three columns.
  4. Select each instance and set its city configuration to a different value — for example, Honolulu, Fairbanks, and New York.
  5. Publish the page.

Each instance renders independently, pulling live weather for its configured city, all from the same deployed bundle.


Step 10: Verify Cleanup Is Working

To confirm disconnectedCallback is actually doing its job:

  1. Open the browser DevTools → Console.
  2. Navigate to the page containing your Weather Card instances.
  3. Navigate away to a different page within the same site (using in-portal navigation, not a full browser reload).
  4. Navigate back.
  5. Confirm there are no console warnings about React state updates on unmounted components, and no obvious memory growth if you check the Memory tab across repeated navigations.

If you temporarily remove the disconnectedCallback method and repeat this test, you should be able to observe the difference — React root instances accumulating with each navigation cycle instead of being cleaned up.


Common Mistakes

1. Forgetting to Mark Dependencies as External

If react, react-dom, and @clayui/* aren't listed in rollupOptions.external, Vite bundles them directly into your component. This works, but defeats the purpose of using Liferay's shared React runtime — you'll end up with your own copy of React shipping alongside whatever the platform already provides.

2. Mismatched htmlElementName and customElements.define()

htmlElementName: weather-card
// Incorrect — Liferay won't be able to render the element correctly
customElements.define('weathercard', WeatherCard);

// Correct — matches exactly
customElements.define('weather-card', WeatherCard);

3. Missing disconnectedCallback

As covered above — the single most common defect in generated or quickly-written custom element code, and the one most likely to only surface after real usage in a navigating portal.

4. Setting instanceable: false on a Component Meant to Repeat

If you intend to place multiple copies of the same component on one page (as with three Weather Cards), instanceable must be true. Left at false, only one instance is permitted per page.

5. Forgetting the base Path in Vite Config

Without a correctly set base in vite.config.js, the built asset references can point to the wrong location once deployed, causing the component to fail to load its own JavaScript or CSS after deployment even though the build succeeded locally.


What Gets Created Behind the Scenes

Component Result
Deployable package Zip archive containing the built web component bundle
Registration Custom element appears in the widget/fragment panel under Client Extensions
Runtime dependencies React, ReactDOM, and Clay resolved via Liferay's JS import maps, not bundled
Lifecycle connectedCallback mounts the React root on insert; disconnectedCallback unmounts it on removal
Instances Each placed instance runs independently, configured via its own attributes

Summary

In this tutorial, you:

  1. Scaffolded a Vite + React project inside a Liferay Workspace client extension folder
  2. Pinned React to Liferay's bundled version to match the runtime you'll actually deploy against
  3. Added Clay UI components for consistent styling
  4. Configured Vite to externalize React, ReactDOM, and Clay rather than bundling them
  5. Walked through three iterations of the Weather Card component, ending with a version that correctly implements connectedCallback and disconnectedCallback
  6. Registered the component as a custom element
  7. Configured client-extension.yaml, including pairing it with a jsImportMapsEntry for easier consumption elsewhere
  8. Built, deployed, and placed three independently configured instances on a page
  9. Verified proper cleanup behavior across page navigations

The lesson worth carrying forward: a custom element isn't just a React component — it's a full web component with its own lifecycle, and skipping disconnectedCallback is the kind of omission that looks completely fine in a five-minute test and only becomes a problem after real users navigate your portal for a while.


References

This tutorial is based on the following official Liferay Learn documentation and a Liferay video walkthrough:


Wasim Shaikh

About the author

Wasim Shaikh is a UI/UX developer and front-end engineer with 15+ years of experience, based in Ahmedabad, India. He specializes in Liferay, React, Angular, Next.js and Tailwind CSS.

Keep reading

Web dev · 8 Jul 2026Creating Your First Client Extension: A Step-by-Step Tutorial (globalJS)Web dev · 8 Jul 2026Creating Your First Client Extension: A Step-by-Step Tutorial (globalCSS)Web dev · 8 Jul 2026Sharing JavaScript Libraries Across Pages: The JS Import Map Entry Client Extension (jsImportMapsEntry)