How to build lazy loaded custom elements with Svelte
How to build lazy loaded custom elements with Svelte
Share on twitter
Share on linkedin
Share on email

If you want to distribute a small and performant component, Svelte is just great.

It is simple, clean and the generated bundle is extremely small, because Svelte is a compiler, it turns your code into a standalone JavaScript code (there is no framework library to load to have your app running).

Moreover it is extremyly simple to generate a custom element from a Svelte component.

But what if your component turns out to be too big?

Nuclia was facing this problem with its search widget. This widget allows to embed Nuclia's search feature in in web page or any web application. It is provided as a custom element, and obviously, we want it to be as small as possible, so it does not impact the performances and the user experience of the hosting page.

On the other hand, we also want it to as featureful as possible, for example when previewing a search result, the options we offer (video player, PDF reader, etc.) are pretty rich. Consequently the JavaScript bundle started to grow.

To mitigate this problem, a good solution is to have a slim (or should I say svelte? 😉) main component covering the initial scenario (in our case, allowing to perform a search and get suggestions or results), and just load the rest only when you need it. That's what is called lazy loading, and it is a well known practice.

Let's see how to achieve that with Svelte.

Lazy loading in dev mode

First, let's create our main component, for the purpose of the example, it will a component displaying a counter:

<script lang="ts">
  let count: number = 0;
  const increment = () => {
    count += 1;
  };
</script>

<button on:click={increment}>
  Clicks: {count}
</button>

To turn it into a custom element, you need to add:

<svelte:options tag="my-counter" />

That is the way you declare to the Svelte compiler you want to obtain a custom element.

Let's say now you have a big sub component:

<script>
  $: console.log('I am a big component');
</script>
<div>Big component</div>

You could use it directly in the Counter component like that:

<script lang="ts">
  import Big from './Big.svelte';
  let count: number = 0;
  const increment = () => {
    count += 1;
  };
</script>

<button on:click={increment}>
  Clicks: {count}
</button>
<Big></Big>

But if you do so, at compile time it will be compile in the same and unique bundle and that's what you want to avoid.

So let's create a lazy loader component:

<script lang="ts">
  import { onMount } from 'svelte';
  let loaded = false;
  let component: any;

  onMount(() => {
    import('./Big.svelte').then((module) => {
      component = module.default;
      loaded = true;
    });
  });
</script>

{#if loaded}
  <svelte:component this={component} {...$$props} />
{:else}
  <slot />
{/if}

It uses the import() function to load the big component implementation and renders it using svelte:component.

Imagine you want to load the big component only when the counter reaches 1, you would do:

<svelte:options tag="my-counter" />

<script lang="ts">
  import LazyLoader from './LazyLoader.svelte';

  let count: number = 0;
  const increment = () => {
    count += 1;
  };
</script>

<button on:click={increment}>
  Clicks: {count}
</button>
{#if count > 0}
  <LazyLoader>Loading…</LazyLoader>
{:else}
  <div>Increase the counter to load the component</div>
{/if}

That's nice, try it out, you will see in your browser dev tool that Big.component.svelte is not loaded until you click on the button.

But wait, how will you compile that properly to keep the lazy loading behavior?

Compiling Svelte components for lazy loading

To compile a regular custom element with Svelte, here are the steps:

  • you need a file exporting the Counter component, src/lib.ts:
export * from "./lib/Counter.svelte";
  • and you need a Vite config like this, vite.lib.config.js:
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import sveltePreprocess from "svelte-preprocess";

export default defineConfig({
  build: {
    lib: {
      entry: "./src/lib.ts",
      name: "MyLibrary",
      fileName: "main-component",
    },
  },
  plugins: [
    svelte({
      include: ["src/lib/Counter.svelte"],
      preprocess: sveltePreprocess(),
      compilerOptions: {
        css: true,
        customElement: true,
      },
    }),
    svelte({
      include: ["src/lib/*.svelte"],
      exclude: ["src/lib/Counter.svelte"],
      preprocess: sveltePreprocess(),
      compilerOptions: {
        css: true,
      },
    }),
  ],
});

As you can see, this config will compile the Counter component as a custom element, and the rest as regular Svelte components.

Now you can run the compilation:

vite build -c=vite.lib.config.js

And you get a .umd.js and a .es.js.
You can use the UMD in any HTML page like that:

<html>
  <head>
    <script src="main-component.umd.js"></script>
  </head>
  <h1>Hello</h1>
  <my-counter></my-counter>
</html>

Obviously, that is not good enough to support lazy loading, the LazyLoader component will try to load something called Big.svelte which will not there in a non-dev context.

The good thing with Svelte is it's a compiler, so we can control the compilation process:

<script lang="ts">
  import { onMount } from 'svelte';
  let loaded = false;
  let component: any;
  const isProd = import.meta.env.PROD;

  onMount(() => {
    if (isProd) {
      import(/* @vite-ignore */ `/my-big-component.umd.js`).then((module) => {
        loaded = true;
      });
    } else {
      import('./Big.svelte').then((module) => {
        component = module.default;
        loaded = true;
      });
    }
  });
</script>

{#if loaded}
  {#if isProd}
    <my-big-component {...$$props} />
  {:else}
    <svelte:component this={component} {...$$props} />
  {/if}
{:else}
  <slot />
{/if}

Two noticeable things here:

  • import.meta.env.PROD allows to know if we are in prod or dev context
  • import(/* @vite-ignore */ '/my-big-component.umd.js') tells to Vite to not try to resolve the module you are importing.

Mow, you have to compile the big component in a secondary bundle that will correspond to this expected my-big-component.umd.js.

So let's go:

  • you mark the big component as a custom element by inserting:
<svelte:options tag="my-big-component" />
  • you export it, src/big-lib.ts:
export * from "./lib/Big.svelte";
  • you create a new config, vite.big-lib.config.js:
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import sveltePreprocess from "svelte-preprocess";

export default defineConfig({
  build: {
    lib: {
      entry: "./src/big-lib.ts",
      name: "MyBigLibrary",
      fileName: "my-big-component",
    },
  },
  plugins: [
    svelte({
      include: ["src/lib/Big.svelte"],
      preprocess: sveltePreprocess(),
      compilerOptions: {
        css: true,
        customElement: true,
      },
    }),
  ],
});
  • and finally, you change the main component config to declare my-big-component.umd.js as external:
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import sveltePreprocess from "svelte-preprocess";

// https://vitejs.dev/config/
export default defineConfig({
  build: {
    lib: {
      entry: "./src/lib.ts",
      name: "MyLibrary",
      fileName: "main-component",
    },
    rollupOptions: {
      external: ["/my-big-component.umd.js"],
    },
  },
  plugins: [
    svelte({
      include: ["src/lib/Counter.svelte"],
      preprocess: sveltePreprocess(),
      compilerOptions: {
        css: true,
        customElement: true,
      },
    }),
    svelte({
      include: ["src/lib/*.svelte"],
      exclude: ["src/lib/Counter.svelte"],
      preprocess: sveltePreprocess(),
      compilerOptions: {
        css: true,
      },
    }),
  ],
});

So now you can compile your two components:

vite build -c=vite.lib.config.js
vite build -c=vite.big-lib.config.js

(Be careful, each call to vite command will clean up the dist forlder, so make sure to copy the files you want to keep before running the command again).

The full source code for this example can be found here, and it also shows how to have a shared component used in both the main component and the lazy loaded one.

Curious about how to use Nuclia in your own code or project?

Curious about how to use Nuclia in your own code or project?

Join our Early adopters community to start exploring Nuclia
Join our Discord channel
Let's build the new search standard!

Related articles

World, meet Nuclia <> Nuclia, meet the world. Making the unsearchable, searchable.
It was back in 2011 when Ramon and I started our first company: an IT consultancy building complex intranets and document management systems for a great number of different companies....
Expose Observable methods as Promises
You use RxJS to implement your libary but you want to let your users decide if they prefer RxJS or regular JavaScript promises. Opinionated choices vs large adoption When you...
Nuclia
Building a great dev team is a huge challenge for most tech companies. Hiring is hard and time consuming. Getting the right people on board while making sure they fit...

Nuclia’s latest articles, right in your inbox

Pick up the topics you are the most interested in, we take care of the rest!