Authoring Custom Elements in a Next.js App

The Intersection of Two Different Ecosystem

  • Published at 
  • Last modified at 
  • 5 mins reading time
This post has been translated into .

Since its creation, my blog has only been a React-exclusive app, which means it only renders a React component. How I store the blog post has changed a lot—from static HTML in a hosted database to markdown files in the same git repository—but the rendering engine stays largely the same.

Unlike most React powered blog, I don't use MDX. I write plain markdown files, but internally I call it MDC.

MDC

To put it simply, MDC is a markdown with the additional of Custom Elements inside of it. I intentionally use custom elements instead of JSX to limit the customization of the post itself. If I wanted to write a full-blown interactive post, I will create another next.js page instead (with or without MDX).

Custom elements is used to provide a standardized way to embed third party widget, one of them is twitter-card.

I write markdown like **this** and render custom element like this
<twitter-card src="https://twitter.com/pveyes/status/1401182609917448196" caption="My teaser tweet for this post"></twitter-card>

That twitter-card custom element will then be rendered like this:

Fatih Kalifo
@pveyes

Teaser for a short blog post I'm currently writing. Was planning to finish it today but I'm exhausted from 6 hours train ride. Hopefully it's ready by tomorrow 🤞

import { html, css, unsafeCSS as cv, LitElement } from 'lit';
import { html as shtml, unsafeStatic as tag } from 'lit/static-html.js';
import { Theme } from '@paper/kraft';

export default class TwitterCard extends LitElement {
  static styles = css`
    ...

	figure > *:first-child {
      margin-bottom: ${cv(Theme.spacing.s)};
    }
  `;
}

See Fatih Kalifo's other tweets
My teaser tweet for this post

The twist? In render time, this custom element was actually transformed into a React component using htmr.

import htmr from 'htmr';
import TwitterCard from './post/TwitterCard';
function Blog({ html }) {
return htmr(html, {
transform: {
'twitter-card': TwitterCard,
},
});
}

That is until today.

Real Custom Elements

My plan was always to actually use custom elements for third party widget, as these are components that doesn't need to be server-rendered, but I haven't got the time to actually do it.

This is the requirements for the implementation:

  1. No changes in build system
  2. Dynamically load custom elements on demand
  3. Interoperability with my current theming system

These requirements ensure I only introduces minimal changes to my blog structure.

No Build Config

There are a few abstraction to create custom elements, most popular one is Lit.

Note: This article won't cover the basic of using & authoring custom elements or Lit. To get up to speed, a quick read about custom elements or Lit should suffice.

Lit heavily emphasize the use of JavaScript decorators that's still in experimental phase, which means you have to configure the build system if you use it. After going through the documentation, I found out that we can use Lit without decorators.

  1. Instead of using @custom-element(), we can call customElements.define
  2. Instead of using @property(), we can define them inside static properties
  3. Instead of using @state(), we can define a property and use state: true option
import { LitElement, html, css } from 'lit';
export default class TwitterCard extends LitElement {
static properties = {
src: { attribute: true },
caption: { attribute: true },
data: { state: true },
};
// for TypeScript
src: string;
caption: string;
data: any;
static styles = css`
figcaption {
font-size: 0.875rem;
}
`;
render() {
return html`
<figure>
<figcaption>${this.caption}</figcaption>
</figure>
`;
}
}
customElements.define('twitter-card', TwitterCard);

The other challenge involving build step is to include class polyfill. As you might know, custom elements can only be defined using real JavaScript class, so it won't work when you transpile it down to ES5. You need to include custom-elements-es5-adapter polyfill to fix this issue, but the polyfill itself can't be transpiled.

There's a few solutions to achieve this, one of them involving vendor-copy which goes against my requirement. My solution is to inline the polyfill using raw.macro inside _document:

import Document, { Html, Head, Main, NextScript } from 'next/document';
import raw from 'raw.macro';
export default class CustomDocument extends Document {
render() {
return (
<Html>
<Head>
<script
type="text/javascript"
dangerouslySetInnerHTML={{
__html: raw(
'@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js'
),
}}
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}

Dynamic Import

The next requirement is to load the code for custom elements only if they are included in the post. To do this, I created a loader component and leverages dynamic import syntax.

import htmr from 'htmr';
import CustomElements from './post/custom-elements/Loader';
function Blog({ html }) {
return htmr(html, {
transform: {
'twitter-card': CustomElements('twitter-card', () => import('./post/custom-elements/TwitterCard))
}
});
}

Loader component is responsible for registering custom elements, as well as loading the code necessary to render the components.

import { createElement, useEffect } from 'react';
export default function CustomElementLoader(
name: string,
importFn: () => Promise<any>
) {
return function Component(props: any) {
useEffect(() => {
Promise.race([
importFn().then((mod) => {
customElements.define(name, mod.default);
}),
customElements.whenDefined(name),
]);
}, [name]);
return createElement(name, props);
};
}

Interoperability with Theming System

I'm using theme-in-css to create theming system using CSS Custom Properties. As this is a framework agnostic library, I should be able to use it inside LitElement style declaration. To do this, I have to use unsafeCSS because the values are defined in separate variable.

I only use it to refer to CSS variables, so I aliased the import as cv.

import { LitElement, css, unsafeCSS as cv } from 'lit';
import { Theme } from '@paper/origami';
export default class TwitterCard extends LitElement {
static styles = css`
figure > *:first-child {
margin-bottom: ${cv(Theme.spacing.s)};
}
`;
}

That's it. Overall I'm happy with this setup. If you're planning to integrate custom elements (with or without Lit), I hope this can be useful to you as an additional reference.

That being said, there are few things that bothers me about Lit.

  1. CLS issue. Because custom elements renders in client-side, loading the content happens in 3 stages: before code, before data, and ready. This is bad for CLS, especially if it's rendered at the top of the post. I'm still experimenting the best way to work around the problem.
  2. Nested CSS selector syntax doesn't work. This means I have to repeatedly write the same selector to target pseudo class like :hover or child combinator selector. Given all CSS-in-JS flavors support this feature, I really wish they supported this feature as well.
  3. To use custom tag constructor (createElement(tag, props) in React) I have to use unsafeStatic and different html tagged template. I know this is and advanced and rare use case, but I wish I could just use unsafeStatic with default Lit html function.
  4. No QoL sugar such as spread props <Component {...props} />, and self closing tag <twitter-card />.

This is not a critic to Lit or custom elements in general, we can argue whether these "features" are necessary or not. I'm just sharing a perspective from a developer who primarily work on React app. I guess I understand more why some React developers are hesitant to use web components. Considering that preact + goober combined can be more powerful yet still have smaller bundle size than Lit, it can be a hard sell.

At the end of the day, I believe it all comes down to personal preference. I'll keep continue using custom elements for third party embed in my post, while the rest of the app uses JSX.

Categorized under

Webmentions

If you think this article is helpful
© 2023 Fatih Kalifa. All rights reserved.