View Source Svelte

This guide walks through configuring a Phoenix + Inertia.js app to render Svelte pages, bundled with esbuild.

A complete, runnable version of the manual setup below lives in examples/svelte.

Scope

The bulk of this guide sets up client-side rendering. Server-side rendering is covered at the end.

Why Svelte needs more than the esbuild CLI

React components written as JS/JSX are the easy case: esbuild bundles them as-is, so the esbuild Hex package — which runs the esbuild command-line tool — is all you need.

Svelte and Vue are different: their single-file components must be compiled to JavaScript first. For Svelte, that compilation runs as an esbuild plugin, and esbuild plugins are only available through esbuild's JavaScript API, never its CLI (evanw/esbuild#884).

That single constraint drives the whole setup: instead of the esbuild Hex package, you install esbuild from npm and drive it from a small Node script. The steps below remove the Hex package and wire Phoenix's watcher and asset aliases to that script.

This is the esbuild path, not the canonical Svelte path

Svelte's ecosystem-standard tooling is now Vite (@sveltejs/vite-plugin-svelte). This guide keeps you on Phoenix's default esbuild pipeline using the community esbuild-svelte plugin. If you'd rather adopt the canonical Svelte toolchain, set up Vite instead.

Install with Igniter

The Igniter installer performs every step below for you — including the server-side wiring — in one command:

mix inertia.install --client-framework svelte

Pass --typescript to set up TypeScript as well. The rest of this guide documents the same setup by hand.

Manual setup

These steps assume the server-side adapter is already installed (the Inertia.Plug, the Inertia.Controller / Inertia.HTML imports, and config :inertia) per the Installation section.

1. Install the npm packages

From your app's assets directory:

npm install svelte @inertiajs/svelte esbuild esbuild-svelte
  • svelte — the framework and its compiler.
  • @inertiajs/svelte — the Inertia client adapter for Svelte.
  • esbuild — the bundler, now as a Node dependency.
  • esbuild-svelte — the plugin that compiles .svelte files during bundling.

TypeScript

Type-only TypeScript in <script lang="ts"> (type annotations, interfaces, import type) works out of the box — esbuild strips the types during the build. Add typescript if you want editor support and type-checking.

You only need svelte-preprocess for TypeScript features that require real transpilation, such as enums. To add it, install svelte-preprocess, then pass preprocess: sveltePreprocess() to the plugin in step 2.

2. Add the esbuild build script

Create assets/esbuild.config.js:

// assets/esbuild.config.js
const esbuild = require("esbuild");
const sveltePlugin = require("esbuild-svelte");

const args = process.argv.slice(2);
const watch = args.includes("--watch");
const deploy = args.includes("--deploy");

const options = {
  entryPoints: ["js/app.js"],
  bundle: true,
  format: "esm",
  splitting: true,
  chunkNames: "chunks/[name]-[hash]",
  outdir: "../priv/static/assets/js",
  logLevel: "info",
  target: "es2022",
  external: ["/fonts/*", "/images/*"],
  minify: deploy,
  sourcemap: watch ? "inline" : false,
  // Required so esbuild resolves Svelte's `svelte` export condition.
  conditions: ["svelte", "browser"],
  mainFields: ["svelte", "browser", "module", "main"],
  plugins: [
    sveltePlugin({
      // Inject component CSS through JS instead of emitting separate .css files.
      compilerOptions: { css: "injected", dev: !deploy },
    }),
  ],
};

async function run() {
  if (watch) {
    const ctx = await esbuild.context(options);
    await ctx.watch();
    console.log("esbuild: watching for changes...");
  } else {
    await esbuild.build(options);
  }
}

run().catch((error) => {
  console.error(error);
  process.exit(1);
});

Two options in here are easy to miss and will silently break your build if omitted:

  • conditions: ["svelte", ...] (and mainFields)@inertiajs/svelte's package.json only exposes a svelte export condition. Without these, esbuild cannot resolve the package at all.
  • compilerOptions: { css: "injected" } — With code splitting enabled, esbuild emits a separate .css file for each lazily-imported page chunk, and ESM dynamic imports never load those sibling stylesheets, so your page styles silently disappear. Injecting the CSS through JS keeps styling correct for lazily-loaded pages and means your root layout needs no extra <link rel="stylesheet"> for component styles.

Content Security Policy

Injected CSS adds component styles via runtime <style> elements. Under a strict CSP that disallows inline styles, you'll need a style-src nonce/hash (or switch to esbuild's external CSS output and link it like the Vue guide does).

3. Set up the Inertia entry point

Replace the contents of assets/js/app.js with the Inertia boot code:

// assets/js/app.js
import { createInertiaApp } from "@inertiajs/svelte";
import { mount } from "svelte";

createInertiaApp({
  resolve: (name) => import(`./pages/${name}.svelte`),
  setup({ el, App, props }) {
    mount(App, { target: el, props });
  },
  http: {
    // Phoenix expects the CSRF token via the `x-csrf-token` header. See the
    // CSRF protection section of the README.
    xsrfHeaderName: "x-csrf-token",
  },
});

The dynamic import of ./pages/${name}.svelte tells esbuild to bundle every file under assets/js/pages into its own chunk, so a page name like "Home" resolves to assets/js/pages/Home.svelte at runtime. mount is the Svelte 5 mounting API.

4. Remove the esbuild Hex package

Since esbuild now runs from Node, drop the Hex package and its configuration.

In mix.exs, remove the :esbuild dependency:

- {:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
  {:tailwind, "~> 0.3", runtime: Mix.env() == :dev},

In config/config.exs, remove the entire config :esbuild block:

- # Configure esbuild (the version is required)
- config :esbuild,
-   version: "0.25.4",
-   my_app: [
-     args: ~w(js/app.js --bundle ...),
-     cd: Path.expand("../assets", __DIR__),
-     env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
-   ]

5. Point the dev watcher at Node

In config/dev.exs, replace the esbuild watcher with a node watcher that runs the build script in watch mode:

  watchers: [
-   esbuild: {Esbuild, :install_and_run, [:my_app, ~w(--sourcemap=inline --watch)]},
+   node: ["esbuild.config.js", "--watch", cd: Path.expand("../assets", __DIR__)],
    tailwind: {Tailwind, :install_and_run, [:my_app, ~w(--watch)]}
  ]

6. Update the asset mix aliases

In mix.exs, point the assets.* aliases at the build script (and npm install) instead of the esbuild tasks:

  defp aliases do
    [
      setup: ["deps.get", "assets.setup", "assets.build"],
-     "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
+     "assets.setup": ["tailwind.install --if-missing", "cmd --cd assets npm install"],
-     "assets.build": ["compile", "tailwind my_app", "esbuild my_app"],
+     "assets.build": ["compile", "tailwind my_app", "cmd --cd assets node esbuild.config.js"],
      "assets.deploy": [
        "tailwind my_app --minify",
-       "esbuild my_app --minify",
+       "cmd --cd assets node esbuild.config.js --deploy",
        "phx.digest"
      ],
      # ...
    ]
  end

(Replace my_app with your app's name.)

7. Load the bundle as an ES module

Code splitting produces ES modules, so the root layout must load the bundle with type="module". In lib/my_app_web/components/layouts/root.html.heex, make sure the <head> uses the Inertia components and a module script:

<.inertia_title>{assigns[:page_title]}</.inertia_title>
<.inertia_head content={@inertia_head} />
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script type="module" defer phx-track-static src={~p"/assets/js/app.js"}>
</script>

8. Create a page and render it

Add a Svelte page at assets/js/pages/Home.svelte:

<script>
  let { name } = $props();
</script>

<h1>Hello from {name}!</h1>

Render it from a controller with render_inertia/2:

def home(conn, _params) do
  conn
  |> assign_prop(:name, "Svelte")
  |> render_inertia("Home")
end

9. Build and run

mix assets.build
mix phx.server

Visit your page and you should see the Svelte component rendered through Inertia. In development, the node watcher rebuilds the bundle whenever you edit a .svelte file.

Server-side rendering

With SSR, Phoenix pre-renders the initial page to HTML through a pool of Node workers and the client hydrates it, instead of rendering into an empty <div id="app">. Subsequent navigation stays client-side.

The steps below are the Svelte-specific pieces; enabling SSR itself — starting the Inertia.SSR pool and setting config :inertia, ssr: true — is covered in the README's Server-side rendering section. A complete version lives in examples/svelte.

1. Add the SSR entry point

Create assets/js/ssr.js, which exports a render(page) function. It uses Svelte 5's server render (aliased, since we also export a function named render):

// assets/js/ssr.js
import { createInertiaApp } from "@inertiajs/svelte";
import { render as renderToHTML } from "svelte/server";

export function render(page) {
  return createInertiaApp({
    page,
    resolve: (name) => import(`./pages/${name}.svelte`),
    setup({ App, props }) {
      return renderToHTML(App, { props });
    },
  });
}

svelte/server is part of svelte, so there's nothing extra to install.

2. Build the SSR bundle

Extend assets/esbuild.config.js to also build ssr.js as a Node/CommonJS module at priv/ssr.js. The SSR build targets Node and compiles components for the server:

const ssr = {
  ...shared,
  entryPoints: ["js/ssr.js"],
  platform: "node",
  format: "cjs",
  outfile: "../priv/ssr.js",
  conditions: ["svelte"],
  mainFields: ["svelte", "module", "main"],
  plugins: [
    sveltePlugin({
      // dev: false — Svelte 5's dev-mode server instrumentation errors during SSR.
      compilerOptions: { generate: "server", css: "injected", dev: false },
    }),
  ],
};

// build/watch both the client and ssr configs

See the example's esbuild.config.js for the full file. Two things to note: generate: "server" is required (the default compiles for the DOM), and dev: false avoids a Svelte 5 dev-mode SSR crash. With injected CSS, the server render inlines component styles into the head, so there's no separate ssr.css. Add priv/ssr.js to your .gitignore.

3. Hydrate on the client

@inertiajs/svelte's createInertiaApp hydrates server-rendered markup and mounts otherwise — automatically, as long as you don't pass a custom setup. So drop the setup/mount from assets/js/app.js:

  import { createInertiaApp } from "@inertiajs/svelte";
- import { mount } from "svelte";

  createInertiaApp({
    resolve: (name) => import(`./pages/${name}.svelte`),
-   setup({ el, App, props }) {
-     mount(App, { target: el, props });
-   },
    http: { xsrfHeaderName: "x-csrf-token" },
  });

4. Enable SSR

Start the Inertia.SSR pool and set config :inertia, ssr: true per the README's Server-side rendering section. Disable it in config/test.exs (config :inertia, ssr: false) so your test suite doesn't need the Node pool.