I love Svelte so much that I want to use it in all of my projects! Often these projects are micro apps that don’t need a full-fledged app framework - they are a few HTML pages with a couple of server-side API endpoints. SvelteKit ends up being overkill for this use case, as it doesn’t need routing, preloading, customized rendering, or pretty much all of the great features provided by SvelteKit.

The Svelte docs don’t give a lot of guidance on how to deploy an app without SvelteKit, in fact there’s only one paragraph in the docs that talk about how this might work -

If you don’t need a full-fledged app framework and instead want to build a simple frontend-only site/app, you can also use Svelte (without Kit) with Vite by running npm init vite and selecting the svelte option. With this, npm run build will generate HTML, JS and CSS files inside the dist directory.

This post will outline how to use Svelte with Express, a minimalist web framework. It is split into two parts - the dev environment and building a container for production deployment, as we use slightly different build steps for each of them.

The setup

Express is a great web framework, it’s super simple for setting up HTTP endpoints and processing requests/responses. There are a variety of middleware plugins to handle things like authnz and rendering. JSON request bodies are natively supported in the latest version of Express. While middleware can significantly extend the capabilities of Express, in this setup we are looking for absolute minimalism. We do need a rendering engine so that we can inject data as properties into our Svelte components from routes on the server. But that’s about it.

Also critical to get working is the build step for using Svelte. Since Svelte is pre-compiled, it’s not as simple as serving Svelte files directly through Express. Instead those assets must be built by Vite before they can be served to the browser client. Vite will package all of our Svelte components into minified Javascript and CSS files, along with any JS dependencies used by components as well as CSS libraries like Tailwindcss with Postcss.

To manage the compilation step, we are going to use some scripting in our local dev environment. It will trigger a build command whenever a .svelte file is created/modified/deleted, and copy the generated files for Express to serve. (Unfortunately, there’s no hot-reload in the browser). For deployment, we’ll compile Svelte as a step in building a Docker container to run on a hosting provider.

Express with html-express-js

Our app is a simple chatbot that receives a message from the Svelte front-end and echos back a random response. It stores chat sessions with a random identifier in memory. It’s not a super complicated app, but we need to route a chat session based on an ID in the HTTP request path. This ID is an example of server-side state that is loaded as properties by Svelte.

We configure Express with a bit of middleware - express.static, express.json, cors and html-express-js. express.static will serve the compiled Svelte js/css from a local ./public/assets directory. json and cors are necessary to allow for fetching data from our Svelte components. And finally, html-express-js provides minimal templating capabilities, so that we can also render data as HTML for GET requests.

Here’s the basic Express backend, as server.js -

// ./server.js
import express, { response } from 'express';
import cors from 'cors';
import { resolve } from 'path';
import htmlExpress from 'html-express-js';

const __dirname = resolve();
const app = express();
const port = process.env.PORT || 3000;
const chats = {};

// Configure html-express-js as the rendering engine.
// Templates will be in the local directory ./public
app.engine(
  'js',
  htmlExpress()
);
app.set('view engine', 'js');
app.set('views', `${__dirname}/public`);

// Configure express to serve static assets from
// the local directory ./public/assets. This is where
// compiled Svelte files will get copied into.
app.use('/assets', express.static(`${__dirname}/public/assets`));

// Configure express to parse json and allow CORS requests
app.use(express.json());
app.use(cors());

// POST endpoint for the front-end to send chats
// The front-end sends a list of all chats on each request
app.post('/:cid', function(req, res) {
  chats[req.params.cid] = req.body;
  res.send("Ok");
});

// GET endpoint for the front-end to load chats by identifier
// The list of chats is sent as a JSON array
app.get('/chats/:cid', function (req, res) {
  let cachedchats = []
  if (req.params.cid && chats[req.params.cid]) {
    cachedchats = chats[req.params.cid]
  }

  res.send(cachedchats);
})

// GET endpoint for loading HTML from the template in public/homepage.js
// If a chat identifier is in the path, parse as :cid and render in template
app.get(['/', '/:cid'], function (req, res, next) {
  res.render('homepage', {
    title: 'Very Cool Chatbot',
    cid: req.params.cid
  });
});

app.listen(port, () => console.log(`Node app listening on port ${port}!`));

Take note of the GET endpoint for the root path - it also parses a cid parameter from the path and passes that into the html-express-js template. You’ll see that parameter used in both the template as well as the root Svelte component below.

Svelte standalone

We set up the Svelte project in a subdirectory ./svelte using Vite, as recommended in the docs -

you can also use Svelte (without Kit) with Vite by running npm init vite and selecting the svelte option.

After initializing and running a build using npm run build, we can take a peek at the generated assets under ./svelte/dist. Of note is the HTML file that gets created, it’s actually pretty simple -

<!-- ./svelte/dist/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- meta tags -->
    <script type="module" crossorigin src="/assets/index-acdd3185.js"></script>
    <link rel="stylesheet" href="/assets/index-972eb3c3.css">
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

The HTML references a .js and .css file that are created during the build step. And there is a single <div id="app"></div> that is used by Svelte to inject the compiled HTML/JS/CSS components that serve as the main web app. This means we can use the same compiled JS/CSS files in any HTML file, as long as there is a div with id="app" somewhere in the template.

html-express-js

We’ll create a similar HTML file as our template that is rendered by Express. It will load the .js and .css file created by building Svelte, and include a single div with id="app". It also will have data rendered from the Express server backend, remember that path parameter called cid from above?

// public/homepage.js
import { html } from 'html-express-js';

export const view = (data, state) => html`
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <title>${data.title}</title>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <!-- Precompiled JS/CSS files by Svelte during build step -->
      <script type="module" crossorigin src="/assets/index-acdd3185.js"></script>
      <link rel="stylesheet" href="/assets/index-972eb3c3.css">
    </head>

    <body>
      <!-- data.cid is the parsed path variable from Express -->
      <div id="app" data-chat-id="${data.cid}"></div>
    </body>
  </html>
`

Wiring it all together

How do we access the data from the template in our Svelte component? Here’s the App.svelte that loads the root of our front-end app. It uses plain JS/HTML DOM to read the value of the data attribute out of the main app div, selecting by id="app". Super easy!

<!-- ./svelte/src/App.svelte -->
<script>
  import Chatbox from "./lib/Chatbox.svelte";

  // Lookup main app div
  const app = document.getElementById("app");
  // Access `data-chat-id` attribute from div
  let chatId = app.dataset.chatId;

</script>

<main class="font-mono text-primary h-full">
    <!-- other markup -->

    <Chatbox {chatId} />
</main>

In my testing, this worked great for simple parameters such as strings. For loading more complex JSON data, I found it didn’t always render correctly in the html-express-js template, especially for embedded string properties that contained quotes. In that case, I would fetch JSON data when mounting the Svelte component instead.

Finally we need copy the generated JS/CSS files from ./svelte/dist/assets into ./public/assets so that they can be served by Express. Given the file names include a random postfix for browser cache busting, we’ll add a bit of code to read JS/CSS file names from the filesystem and render them in the template:

//./server.js
// ...other includes

import fs from 'fs';

const __dirname = resolve();

function readAssets() {
  const js = fs.readdirSync(`${__dirname}/public/assets`).filter((f) => f.endsWith('.js'));
  const css = fs.readdirSync(`${__dirname}/public/assets`).filter((f) => f.endsWith('.css'));

  return { js, css };
}

// ...other setup and endpoints

app.get(['/', '/:cid'], function (req, res, next) {
  const { js, css } = readAssets();
  res.render('homepage', {
    title: 'Very Cool Chatbot',
    cid: req.params.cid,
    js,
    css
  });
});
// public/homepage.js
import { html } from 'html-express-js'

const renderJs = (js) => {
  return js.map((j) => `<script type="module" crossorigin src="/assets/${j}"></script>`).join('\n');
}

const renderCss = (css) => {
  return css.map((c) => `<link rel="stylesheet" href="/assets/${c}" />`).join('\n');
}

export const view = (data, state) => html`
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <title>${data.title}</title>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      ${renderJs(data.js)}
      ${renderCss(data.css)}
    </head>

    <body data-theme="forest">
      <div id="app" data-chat-id="${data.cid}"></div>
    </body>
  </html>
`

DevEx

In review so far - we’ve set up an Express app to serve static assets, created a stand-alone Svelte app, and added a template that initializes the Svelte app from Vite compiled assets as well as renders data from the server. However this still requires building the Svelte app and copying the compiled asset files into a directory for Express to serve, which is a lot of commands to run while actively modifying the Svelte app. Let’s set up some scripts to make this easier.

First, a simple Makefile to run the Vite build and copy the compiled files into Express asset directory -

# ./Makefile
SHELL := /bin/bash

build:
	pushd svelte 
	&& npm install 
	&& npm run build 
	&& popd 
	&& rm -f ./public/assets/* 
	&& cp ./svelte/dist/assets/* ./public/assets/

run:
	npm install 
	&& node server.js

We also create a small shell script called ./watch-svelte to watch for changes to *.svelte files and run the build automatically. This saves us from having to run a command every time we modify a Svelte component -

# ./watch-svelte
#!/bin/bash

inotifywait -q -m -r -e modify,create,delete ./svelte | while read DIRECTORY EVENT FILE; do
    if [[ $FILE == *.svelte ]]; then
        make build
    fi
done

Production build

To deploy the app to production, we’ll create a Dockerfile that both builds the Svelte app and packages the Express app. The resulting container can be run in many hosting providers, for this example we deployed to fly.io

The Dockerfile uses a multi-stage build, which helps to keep the resulting container size smaller as we won’t include any of the Vite or Svelte dev dependencies.

FROM node:18-alpine as svelte-build

RUN mkdir -p /home/node/svelte/node_modules && chown -R node:node /home/node/svelte

WORKDIR /home/node/svelte

ADD svelte ./
RUN npm install
RUN npm run build

FROM ubuntu:latest

RUN apt-get update && apt-get install -y gnupg2 wget vim
RUN wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - 
    && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' 
    && wget -q -O - https://deb.nodesource.com/setup_16.x | bash - 
    && apt-get update 
    && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends nodejs
    && rm -rf /var/lib/apt/lists/*

RUN useradd -Ums /bin/bash node
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app

WORKDIR /home/node/app
USER node

ADD public ./public
COPY --from=svelte-build --chown=node:node /home/node/svelte/dist/assets/* ./public/assets/

COPY server.js ./
COPY package.json ./
RUN npm install

EXPOSE 3000

CMD node server.js

And that’s it! Hopefully this is a useful reference for how you might use Svelte in other micro apps where SvelteKit’s capabilities are extraneous.

Happy coding,

  • Ryan

Header image generated with the assistance of DALL·E 2