From React App to React Client Extension in 5 Easy Steps

Want to transform your React app into a Client Extension? I'll show you how in this post...

Introduction

So I've been working on my Objects Rule! series and have been working on an app I plan on showing off soon, but I realized that there is probably a gap explaining, well, how do you get started?

I mean, it's one thing to go and check the client extensions sample repo here: https://github.com/liferay/liferay-portal/tree/master/workspaces/liferay-sample-workspace, find the sample React custom elements such as liferay-sample-custom-element-2 (a simple React CX), liferay-sample-custom-element-4 (a CX using Liferay's React), or liferay-sample-custom-element-5 (a CX using Liferay's React and Clay), copy them and try to get them to work...

But that's actually kind of hard and might be prone to problems...

It's hard because if you need to include some other library, well how do you do that if you're using Liferay's React? How will it reconcile and load the dependencies?

And if Liferay's React changes version in the next quarterly release, will your custom element continue to work or will it require an update? How will you know if your custom element starts failing because of a Liferay React version bump?

And besides, if you're a React developer (not necessarily a Liferay developer), you'll likely want to use standard tooling you're familiar with to create your React application, not copying and modifying Liferay's custom element...

So in this blog, I'm starting from the perspective of "I'm an experienced React developer who uses standard React tools, how can I get these working under Liferay?"

Hopefully this will prove to be useful to some of you out there...

Preparing a Workspace

Since I'm an experienced React developer but maybe new to Liferay, some of what follows is going to be new to me, but it is required to develop for Liferay.

First thing I need is to have what is called a Liferay Gradle Workspace:

  • Workspace because it contains all Liferay customizations and applications and coordinates development, builds and testing with Liferay.
  • Gradle because Liferay is a Java application and the Liferay tooling all leverages Gradle for building the various artifacts, including our React custom elements.
  • Liferay because, well, we're going to be deploying to Liferay.

So how do we get a Liferay Gradle Workspace for our React elements?

One prerequisite we need to have is a JDK 11 installation on our system. One that works great is Azul's Zulu 11. Use this link, find your OS and architecture and download the Zulu 11 package and expand on your system. You'll want to set a JAVA_HOME environment variable to point to where you expanded the download, and also add $JAVA_HOME/bin to your PATH environment variable.

Well, the steps to get a Liferay Gradle Workspace are going to be strange, and it's even strange for Java developers, but it's what is necessary...

We need to copy the liferay-sample-workspace that we can find in the Liferay Portal Github repo here: https://github.com/liferay/liferay-portal/tree/master/workspaces/liferay-sample-workspace

It's not available as a separate repository, so we can't just download the zip of that folder alone. And the Liferay Portal repo is huge, so we don't want to clone or fork it either.

The easiest method is to just go to https://github.com/liferay/liferay-portal/, download the ZIP from the master branch, expand it and then copy the whole liferay-sample-workspace folder to some location on your drive where you plan to put all of your React elements.

I'm going to copy mine as liferay-react-workspace for the rest of this blog.

The last thing we'll do is edit the liferay-react-workspace/gradle.properties file, and we're going to change the value for the liferay.workspace.product key to portal-7.4-ga112 so we can use Liferay CE. If using DXP, just set the value to the appropriate version of the bundle that you're targeting.

So, why have we done all of this?

The Liferay Gradle Workspace contains special tooling that knows how to package and bundle custom React elements into React Client Extensions. The steps we've done here will give us this tooling so we can package our custom elements. Even though we may not know Gradle or any of the other aspects of the Liferay Gradle Workspace, we'll learn enough going through this blog to get the custom React elements developed, built, deployed and tested in Liferay.

Now that the prep work is done, we can get back to the main topic...

Creating a New Project

For the purposes of this blog, we're going to create a new React application. As an experienced React developer, I probably already have an app that I've created and that I want to turn into a React Client Extension (CX), but I'm going to start a new one so that you too will be able to do the same on your next React CX...

Change to the liferay-react-workspace/client-extensions directory.

So, first thing we're going to do is create a new React app by issuing the following command:

$ yarn create react-app my-react-app

And like John Oliver on Last Week Tonight, I have tricked you! You may not have known that create-react-app was deprecated in 2023 and I just used it in 2024 to create a new React project!

Okay, okay, I'm not as funny as John Oliver. An experienced React developer would really be using the command:

$ yarn create vite my-react-app --template react

Vite is the new trendy way to create a React app, although there are others such as Next.js or Remix.

Note that I'm also using Yarn instead of NPM. You can use NPM, but in the Liferay workspace it is often better to use Yarn as it saves duplicate downloading of modules.

Either way, we're going to end up with a new React project that we will be adapting to become a React CX, and either way I'll cover the changes that we need to make to leverage the React app starting point.

If you already have existing React apps to migrate, begin from this point...

Step 1: Update package.json

The package.json is the key for your React app and, for the most part, whatever you have in there is going to be just fine.

Well, with one exception - the name.

The name of your React app as defined in package.json will turn into the custom element name, and in Liferay, all custom element names must be unique.

For Liferay-created elements, you'll typically find the names are defined like @liferay/descriptive-element-tag-name format to ensure they will be unique and also distinguishable from your own. Using the @liferay scope ensures that their descriptive-element-tag-name will not collide with your own React app named descriptive-element-tag-name.

As a best practice, you should use a scope with all of your React applications to help avoid collisions with others.

If you've created the my-react-app as specified in the previous section, this is likely not going to be unique, especially since every React example out there has you calling the project my-react-app.

So we'll set ours to my-react-custom-element and, for good measure, let's put it in the @liferay scope. We really shouldn't do this since we don't own the @liferay scope, but I don't think they'll mind for the purposes of this blog.

Best practice is to name the project the same as the custom element. With my-react-app as the project but my-react-custom-element as the custom element, it can be confusing seeing the distribution as my-react-app.zip but it contains a custom element with a different name. Naming the project the same as the custom element avoids that confusion.

By default, new projects created using create-react-app or vite will use React 18. This is totally fine, but if you want to use Liferay's version of React, you must change to version 16.

There are some things in package.json that you might want to change, but that's up to you. For example, you might pull out the test dependencies, remove the linter, and clean up other things you don't need. For a minimalist package.json example, check this one from the Liferay sample workspace: https://github.com/liferay/liferay-portal/blob/master/workspaces/liferay-sample-workspace/client-extensions/liferay-sample-custom-element-2/package.json

Step 2: Update index.html

So we changed the name in the package.json, we need to make sure that our custom tag is listed correctly in public/index.html or index.html (depending upon if you used create-react-app or vite to create your application).

Edit the file and look for the line that reads:

<div id="root"></div>

This line, we'll need to replace with:

<my-react-custom-element></my-react-custom-element>

Most React apps are Single Page Applications (SPAs), so there is typically only one root div to worry about. In Liferay where we may have multiple React custom elements, we need a unique identifier so our custom element does not get clobbered by some other React custom element.

If you will be running your React application outside of Liferay (i.e. for development and testing) and you are using Clay, you should add the following to the <head> area:

<link href="https://cdn.jsdelivr.net/npm/@clayui/css/lib/css/atlas.css" 
  rel="stylesheet" />

This will pull in the default Clay styles so your Clay components render correctly.

Step 3: Change Connecting Logic

This next change will be in src/index.js. I'll show the change we're going to make, then explain it...

We're going to chop the code out that is standard:

create-react-app:

import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

reportWebVitals();

vite:

import ReactDOM from 'react-dom/client'

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
)

and replace this code with:

import {render, unmountComponentAtNode} from 'react-dom';

class WebComponent extends HTMLElement {
  connectedCallback() {
    render(<React.StrictMode>
        <App />
      </React.StrictMode>, this);
  }

  disconnectedCallback() {
    unmountComponentAtNode(this);
  }
}

const ELEMENT_NAME = 'my-react-custom-element';

if (customElements.get(ELEMENT_NAME)) {
  // eslint-disable-next-line no-console
  console.log(`Skipping registration for <${ELEMENT_NAME}> (already registered)`);
} else {
  customElements.define(ELEMENT_NAME, WebComponent);
}

The chunk of code that we removed was the boilerplate code that create-react-app or vite gives us in a new React application. It is responsible for connecting the React application to the root div declared in index.html.

However, this kind of thing won't work for us on a Liferay page because of the dynamic nature of Liferay. We need a bit more to get the connection right and support the way Liferay renders pages.

Hence the replacement code...

First we create a special component called WebComponent that extends the standard HTMLElement and uses the connectedCallback() and disconnectedCallback() to manage what happens when the custom React element is added to or removed from a page.

When the React custom element is added to a page, the connectedCallback() method creates a new root element, and then has React render the same application from the generated code, but into this new root element.

The disconnectedCallback() gets invoked when the React custom element is no longer on the page. Since Liferay will be dynamically changing the page, our custom React element can be added or removed numerous times. When the remove happens and the disconnect is triggered, this callback will have React unmount the custom application, basically freeing up any memory that the component occupied. If we didn't do this, we would be generating a memory leak in the browser that, over time, will cause problems for the end user.

The final step in the replacement code is to define the React custom elements so when the browser sees the <my-react-custom-element> tag, it will know to use the WebComponent to handle rendering the application.

Step 4: Create the client-extension.yaml File

The next step is to create a special file that Liferay uses when building and deploying the custom React element Client Extension. It is this file which is the key to this being a CX that Liferay can handle. It is also a signal to the Liferay Gradle Workspace that this project can be built into a CX zip file suitable for deployment to Liferay.

Here's the client-extension.yaml file that we would use for the create-react-app version:

assemble:
    - from: build/static
      into: static
my-react-custom-element:
    cssURLs:
        - css/main.*.css
    friendlyURLMapping: my-react-custom-element
    htmlElementName: my-react-custom-element
    instanceable: false
    name: My React Custom Element
    portletCategoryName: category.client-extensions
    type: customElement
    urls:
        - js/main.*.js
    useESM: true

And here's the client-extension.yaml file that we would use for the vite version:

assemble:
    - from: vite-build
      into: static
my-react-custom-element:
    friendlyURLMapping: my-react-custom-element
    htmlElementName: my-react-custom-element
    instanceable: false
    name: My React Custom Element
    portletCategoryName: category.client-extensions
    type: customElement
    urls:
        - assets/*.js
    useESM: true

As you can see, they really are very similar...

The assemble block identifies where the build assets are coming from, for create-react-app they end up in build/static, and in vite they end up in vite-build (because of the Vite config in Step 5). In both cases, we are locating the assets in the static folder.

The my-react-custom-element block has many similarities. The name of the block should always match the friendlyURLMapping and htmlElementName, and the name should be set to something meaningful (this is the name that will show up in the Liferay UI when page editors are placing your React custom element on the page).

The differences for the urls stem from how the build artifacts are arranged. In create-react-app, build/static/js is where the main compiled JS file is (the wildcard matches the random name it will have), etc. For vite, it is under the assets folder.

Step 5: Update Vite Configuration

So this step doesn't apply to the create-react-app folks, this is only for vite.

We need to update the configuration so vite can do the build correctly.

We need to change from the default vite.config.js:

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

export default defineConfig({
  plugins: [react()],
})

to the updated vite.config.js:

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

export default defineConfig({
  base: '/o/my-react-custom-element',
  build: {
    outDir: './vite-build',
    rollupOptions: {
      external: [
        'react',
        'react-dom',
        /^(?!@clayui\/css)@clayui.*$/,
      ],
    }
  },
  plugins: [
    react({
      jsxRuntime: 'classic',
    }),
  ]
})

The base sets the base URL that Liferay will be using to serve the assets, so /o/my-react-custom-element path has the suffix to match the name we've been using everywhere else. If our project name is different from the element name, such as earlier where we had my-react-app, the base URL would be /o/my-react-app. This can be confusing and is one of the reasons having the project name match the custom element name will make things easier for us.

The build configuration overrides where the build occurs, this has to be changed to avoid conflicts with the Liferay build process. In this case we're using vite-build, and this matches the assembly from the client-extension.yaml file we just created. The rollupOptions externals list the modules provided outside of our build that don't need to be directly included. Here we list React, React DOM and the Clay artifacts; this means we'd be leveraging Liferay's versions. We could just as well go without the externals and include whatever version of React, Clay, etc. that we wanted to.

Since we have the externals excluding React, we must be using React 16 in the package.json file. To use React 18, we would need to remove the rollupOptions and include React 18 in our project.

The plugins sets the JSX runtime to Classic, or React 16. This is the version that Liferay uses, so it will allow you to leverage React from Liferay.

We only need the plugins section if we are using Liferay's version of React. If not, we do not need the plugins or the rollupOptions and can use React 18 in the package.json file.

Building and Deploying the React Client Extension

So your 5 easy steps to migrate our React app into a React Client Extension (CX) are now done (4 steps if you're using create-react-app).

We need to build the deployment artifact that Liferay expects, and we're going to have to use the Java developer's toolset to get this part done.

In a terminal window open to client-extensions/my-react-custom-element, we're going to issue the command:

$ ../../gradlew build

This will actually build the distributable CX zip file, which in our case will be dist/my-react-custom-element.zip. If we had the project name different than the element name i.e. my-react-app, then we'd find a dist/my-react-app.zip file for distribution.

This is the zip file that we need to deploy to Liferay. We can hand it off to the Liferay administrator and they'll drop it into the osgi/client-extensions folder if running Liferay PaaS or Liferay Self Hosted. If we're on Liferay SaaS, the Liferay administrator would use the command line tool to upload the zip file to Liferay SaaS for deployment.

Since our React app is a full-blown React application, we can run it locally like we would any other React application; the scripts are there to run it locally, and for all of the changes we've made, the custom element will still be placed properly in index.html when rendered and function as expected.

We can also test it locally in Liferay too. That way we can see just how our custom element will work in Liferay.

Use the following command from the root of the Liferay Gradle Workspace:

$ ./gradlew initBundle

This will set up a local Liferay instance in the bundles directory that is ready to run (you can get help from your fellow Liferay developers to know how to start the Tomcat application server).

When this bundle is running, from the client-extensions/my-react-custom-element directory you can use the command:

$ ../../gradlew deploy

This will build and deploy the client extension into Tomcat in bundles/osgi/client-extensions so you can switch to your browser window and place and play with your React custom element within Liferay.

Conclusion

Whether you're an experienced React developer who needs to build an application to run on Liferay, or you're an experienced Liferay developer looking to do more React development, hopefully this blog will provide the details you need to be able to adapt your React app so it can run under Liferay.

Your Liferay Gradle Workspace now has your React custom element, but there's some other custom element client extensions to look at (some using React, one using Angular). You can check out the other client extensions in the directory or delete them all and only keep your custom React elements around, it's entirely up to you.

Have any questions or comments? Drop them below or find me on the Liferay Community Slack!

Blogs

Another great piece to the puzzle, thanks David.

 

I would also like to mention possibility to develop CE with hotswap within Liferay page.

For us this works the best since we can see the whole page with all the resources included not just the CE (also same page context, sesion, cookies..). We are using what Testray team presented in /dev/24 (link to the presentation https://youtu.be/1wfUCTNqG58?t=7338) and its been wonderful, I am very grateful for it.

If I should do quick summary it uses Vites backend integration https://vitejs.dev/guide/backend-integration.html

Basically you just include 3 files in your manually created development CE and it works very nicely and very fast.

Thanks Martin!

Yeah there's a lot more that can be done outside of the 5 easy steps. This post was meant to help React developers get their projects on Liferay, then tackle more advanced topics like hotswap, shared modules, ...

Hi David,

Thanks for the blog, steps 1 to 5 are very clear, but to actually build the zip file you told to use this ../../gradlew build command but how it will work wihtout a liferay workspace, and if we need to use a liferay workspace where to copy this custom element folder, could you please specifiy that steps as well.

Created a Liferay workspace and created the react project under modules folder then if I try to build it is giving error "Execution failed for task ':modules:my-react-app:packageRunBuild'. > Process 'command 'cmd'' finished with non-zero exit value 1". 

It's important to build the React apps from the client-extensions folder in the workspace, not the modules folder. The modules folder is reserved for OSGi (Java) modules. The React apps need to be in the client-extensions folder for the workspace to handle them correctly.