Gravatar of Jeroen Jeroen Pelgrims

Render Svelte component to string

Posted on in software-development, svelte

Background§

For a new hobby project I'm working on I implemented the login using a magic link.
So the user would enter their email address and they'd receive an email with a link in it that they can click on.
When arriving back on the site they would be authenticated.

As a quick way to build the body of the mail I just concatenated some strings together.
But after building some more features it was clear that I needed to send different type of mails.
To invite users to teams, to send notifications, etc.

The project itself is built with SvelteKit, so it would be cool if I could use Svelte components to render the body of the email.
In essence this means rendering a Svelte component to a string in a .ts file instead of a .svelte file.

Want to go straight to the code?§

Code for used in this blog post can be found in this Github repo

Note§

This post was written and tested with Svelte 4.2.16 and SvelteKit 2.5.7, so I'm not sure if this will work for later versions.

1. Rendering a Svelte component in the backend§

Defining the component we want to render to a string§

First of all we'll define a simple component.

// lib/Hello.svelte
<script lang="ts">
  export let name: string;
</script>

<div>
  Hello, <strong>{name}</strong>
</div>

Showing the rendered string value somewhere§

To demonstrate the rendering of this component to a string we'll create a +page.server.ts and +page.svelte file.
We'll render the component to a string in the load function of the +page.server.ts file and then pass the value to the +page.svelte file through the data prop.

Our +page.svelte file looks like this:
It's just showing the rendered html of the component in a pre tag.

<script lang="ts">
import type { PageData } from "./$types";
export let data: PageData;
</script>

<main>
  This is the string value of the rendered Hello component:
  <pre>{data.stringValue}</pre>
</main>

Rendering the component in the backend to a string value§

This is the actual meat of the solution.

NOTE: If you want to do this in the frontend the process is a bit different.

A component imported in SvelteKit in a backend file will have a render function.
See the documentation about it here: https://svelte.dev/docs/server-side-component-api

Unfortunately there doesn't seem to be a way to get access to this render function in a typesafe way.
Even the docs page doesn't show any types when switching to Typescript mode.

We can test out the render function by casting to any and seeing what it returns.

import type { Load } from "@sveltejs/kit";
import Hello from '$lib/Hello.svelte';

export const load: Load = async ({  }) => {
  const result = (Hello as any).render({ name: "Jeroen" });
  console.log(result);
};

This logs out something like this:

{
  html: '<div>Hello <strong>Jeroen</strong>!</div>',
  css: { code: '', map: null },
  head: ''
}

Based on this we could define a type for the result of the render function like this:

type RenderResult = {
  head: string;
  html: string;
  css: {
    code: string;
    map: string | null;
  }
}

A basic definition of a type for the render function itself could be this:

type RenderFunction<TProps> = (props: TProps) => RenderResult;

But if we want to be a bit more typesafe and tie the type of the props to the type of the component we could define it like this:

import type { ComponentType } from "svelte";

type ComponentProps<TComponent extends ComponentType> =
  ConstructorParameters<TComponent>[0]["props"];

type RenderFunction<
  TComponent extends ComponentType,
  TProps = ComponentProps<TComponent>
> = (props: TProps) => RenderResult;

Using these types we can easily define a type that extends a component type with a typesafe render function:

type ComponentWithRender<TComponent extends ComponentType> = TComponent & {
  render: RenderFunction<TComponent>
};

And finally we can define a function that takes a component and returns the rendered value of the component:

function renderToString<TComponent extends ComponentType>(
  Component: TComponent,
  props: ComponentProps<TComponent>
) {
  const { render } = Component as ComponentWithRender<TComponent>;
  const { html } = render(props);
  return html;
}

Updating our load function to use our new typesafe function§

The result is the same as casting the component to any and calling the render function, but now it's type safe.
We know we're passing in the right props to the component.

// +page.server.ts
import type { Load } from "@sveltejs/kit";

export const load: Load = async ({  }) => {
  const stringValue = renderToString(Hello, { name: "Jeroen" });
  return { stringValue }
};

2. What about styling?§

Looking at what the render function returns, we see there's also a css object being returned.
That object has 2 properties: code and map.
It's the code property we're interested in. This is what contains the css classes that apply to our component.

Let's update our Hello component to have some css:

<script lang="ts">
export let name: string;
</script>

<div>
Hello, <strong>{name}</strong>!
</div>

<style lang="scss">
strong {
  color: red;
}
div {
  border: 1px solid greee
}
</style>

If we check what the render function returns now, it will return something this:

{
  html: '<div class="s-Z_aSIPqeSqkG">Hello, <strong class="s-Z_aSIPqeSqkG">Jeroen</strong>!\n' +
    '</div>',
  css: {
    code: 'strong.s-Z_aSIPqeSqkG{color:red}div.s-Z_aSIPqeSqkG{border:1px solid green}.s-Z_aSIPqeSqkG{}',
    map: null
  },
  head: ''
}

We can now update our renderToString version to also return the css and apply it to the html:

export function renderToString<TComponent extends ComponentType>(
  Component: TComponent,
  props: ComponentProps<TComponent>
): string {
  const { render } = Component as ComponentWithRender<TComponent>;
  const { html, css } = render(props);
  return `<style>${css.code}</style>${html}`;
}

Svelte handles the scoping for us, so we don't need to worry about the css applying to anything else than just the html of our component.

Mail clients aren't browsers§

In the opening of this post I said that my main intention was to make mail templates.
If your plan is the same, keep in mind that mail clients don't render html the same way as most browsers.
They usually don't follow the latest standards but are lagging behind by a (few) decade(s).

For more info about this you can check this page or this page.

My plan is to see if I can combine the method I'm using above with this markup language specifically designed for designing emails.

This post is written by Jeroen Pelgrims, an independent software developer who runs Digicraft.eu.

Hire Jeroen