A starter template for building modern web applications with Phoenix 1.8+, Inertia.js, and Svelte 5 using esbuild (not Vite).
Setting up Inertia.js with Svelte 5 and Phoenix can be tricky. The main gotcha is that Inertia expects components in ES module format ({ default: Component }), but esbuild's static imports return components directly. This template has the fix pre-configured so you can get started immediately.
export let syntax for best Inertia compatibilityBefore you begin, ensure you have the following installed:
macOS (using Homebrew):
brew install elixir
Ubuntu/Debian:
wget https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb
sudo dpkg -i erlang-solutions_2.0_all.deb
sudo apt-get update
sudo apt-get install esl-erlang elixir
Using asdf (recommended for version management):
asdf plugin add erlang
asdf plugin add elixir
asdf install erlang 26.2
asdf install elixir 1.16.0-otp-26
asdf global erlang 26.2
asdf global elixir 1.16.0-otp-26
Verify installation:
elixir --version
# Erlang/OTP 26 [erts-14.x]
# Elixir 1.16.x
macOS (using Homebrew):
brew install node
Using nvm (recommended):
nvm install 20
nvm use 20
Verify installation:
node --version # v18.x or higher
npm --version # 9.x or higher
After installing Elixir, install Hex (Elixir's package manager) and the Phoenix application generator:
mix local.hex --force
mix archive.install hex phx_new --force
git clone https://github.com/yourusername/phoenix_inertia_svelte_template.git my_app
cd my_app
Replace all occurrences of the app name:
# On macOS/Linux, you can use sed:
# Replace "phoenix_inertia_svelte" with "my_app" (snake_case)
# Replace "PhoenixInertiaSvelte" with "MyApp" (PascalCase)
find . -type f -name "*.ex" -o -name "*.exs" -o -name "*.heex" -o -name "*.js" -o -name "*.json" | \
xargs sed -i '' 's/phoenix_inertia_svelte/my_app/g; s/PhoenixInertiaSvelte/MyApp/g'
# Also rename the lib directories
mv lib/phoenix_inertia_svelte lib/my_app
mv lib/phoenix_inertia_svelte_web lib/my_app_web
mv lib/phoenix_inertia_svelte_web.ex lib/my_app_web.ex
Run the setup command which installs both Elixir and Node.js dependencies:
mix setup
This runs:
mix deps.get - Fetches Elixir dependenciesmix assets.setup - Installs Tailwind and Node.js dependenciesmix assets.build - Builds CSS and JavaScript assetsmix phx.server
Or run inside IEx (Interactive Elixir) for debugging:
iex -S mix phx.server
Open http://localhost:4000 in your browser.
If you prefer to install dependencies manually or mix setup fails:
mix deps.get
cd assets
npm install
cd ..
mix tailwind.install
# Build Tailwind CSS
mix tailwind phoenix_inertia_svelte
# Build JavaScript with esbuild
cd assets && node build.js && cd ..
mix phx.server
| Package | Version | Purpose |
|---|---|---|
phoenix |
~> 1.8.0 | Web framework |
phoenix_html |
~> 4.2 | HTML helpers |
phoenix_live_view |
~> 1.0 | Real-time features (optional) |
phoenix_live_reload |
~> 1.2 | Development hot reload |
inertia |
~> 2.5 | Inertia.js Phoenix adapter |
bandit |
~> 1.6 | HTTP server |
tailwind |
~> 0.3 | Tailwind CSS integration |
esbuild |
~> 0.9 | JavaScript bundling (for Tailwind) |
jason |
~> 1.2 | JSON encoding/decoding |
dns_cluster |
~> 0.1.1 | DNS-based clustering |
telemetry_metrics |
~> 1.0 | Metrics |
telemetry_poller |
~> 1.0 | Telemetry polling |
| Package | Version | Purpose |
|---|---|---|
@inertiajs/svelte |
^2.0.0 | Inertia.js Svelte adapter |
svelte |
^5.0.0 | Svelte framework |
esbuild |
^0.24.0 | JavaScript bundler |
esbuild-svelte |
^0.9.0 | Svelte plugin for esbuild |
svelte-preprocess |
^6.0.0 | Svelte preprocessor |
├── assets/
│ ├── css/
│ │ └── app.css # Tailwind CSS entry point
│ ├── js/
│ │ ├── app.js # Inertia.js setup & page registry
│ │ └── pages/
│ │ └── Home.svelte # Sample Svelte page component
│ ├── build.js # esbuild configuration
│ ├── package.json # Node.js dependencies
│ └── tailwind.config.js # Tailwind configuration
├── config/
│ ├── config.exs # Base configuration (Inertia config here)
│ ├── dev.exs # Development settings
│ ├── prod.exs # Production settings
│ ├── runtime.exs # Runtime configuration
│ └── test.exs # Test settings
├── lib/
│ ├── phoenix_inertia_svelte/
│ │ └── application.ex # Application supervisor
│ └── phoenix_inertia_svelte_web/
│ ├── components/
│ │ ├── layouts.ex # Layout module
│ │ └── layouts/
│ │ ├── root.html.heex # Root HTML template
│ │ └── app.html.heex # App layout
│ ├── controllers/
│ │ ├── error_html.ex # HTML error handling
│ │ ├── error_json.ex # JSON error handling
│ │ └── page_controller.ex # Sample controller
│ ├── endpoint.ex # Phoenix endpoint
│ ├── router.ex # Routes definition
│ └── telemetry.ex # Telemetry setup
├── priv/
│ └── static/ # Static assets (generated)
├── test/ # Test files
├── mix.exs # Elixir project definition
├── .formatter.exs # Elixir formatter config
└── .gitignore
/)router.ex) matches the route and calls the controllerpage_controller.ex) prepares props and calls render_inertia("PageName")<div id="app" data-page="..."> containing JSON propsapp.js) picks up the data-page attribute and mounts the Svelte componentAfter the initial page load, Inertia intercepts link clicks and:
<!-- assets/js/pages/About.svelte -->
<script>
export let title = "About Us";
export let description = "";
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>
<main class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold">{title}</h1>
<p class="mt-4 text-gray-600">{description}</p>
</main>
// assets/js/app.js
import Home from "./pages/Home.svelte";
import About from "./pages/About.svelte";
const pages = {
Home: { default: Home },
About: { default: About }, // Add this line
};
# lib/phoenix_inertia_svelte_web/controllers/page_controller.ex
def about(conn, _params) do
conn
|> assign_prop(:title, "About Our Company")
|> assign_prop(:description, "We build amazing things.")
|> render_inertia("About")
end
# lib/phoenix_inertia_svelte_web/router.ex
scope "/", PhoenixInertiaSvelteWeb do
pipe_through :browser
get "/", PageController, :home
get "/about", PageController, :about # Add this line
end
This is the key configuration that makes everything work. When using esbuild (not Vite), you must wrap component imports in the ES module format Inertia expects:
// assets/js/app.js
// Import components directly
import Home from "./pages/Home.svelte";
// Wrap in module format { default: Component }
const pages = {
Home: { default: Home }, // Correct - Inertia expects this format
// Home: Home, // WRONG - will cause blank page!
};
Why? Inertia.js expects import() or import.meta.glob() which return { default: Component }. With esbuild static imports, you get the component directly, so we manually wrap it.
This template uses Svelte 5 with the legacy export let syntax for props. This ensures maximum compatibility with Inertia.js:
<!-- Recommended: Legacy style (best compatibility) -->
<script>
export let title;
export let users = [];
</script>
<!-- Also works but may have edge cases -->
<script>
let { title, users = [] } = $props();
</script>
Located in config/config.exs:
config :inertia,
endpoint: PhoenixInertiaSvelteWeb.Endpoint,
static_paths: ["/assets/app.js", "/assets/app.css"],
default_version: "1",
camelize_props: true, # snake_case -> camelCase
ssr: false, # Server-side rendering (disabled)
raise_on_ssr_failure: false
Located in assets/tailwind.config.js. The content paths include Svelte files:
content: [
"./js/**/*.js",
"./js/**/*.svelte", // Include Svelte components
"../lib/phoenix_inertia_svelte_web.ex",
"../lib/phoenix_inertia_svelte_web/**/*.*ex",
],
mix phx.server
mix test
mix format
# Tailwind CSS
mix tailwind phoenix_inertia_svelte
# JavaScript (with watch mode)
cd assets && node build.js --watch
mix assets.deploy
This minifies CSS and JavaScript and generates digested filenames.
mix phx.gen.secret
SECRET_KEY_BASE=your_64_char_secret \
PHX_HOST=example.com \
PORT=4000 \
PHX_SERVER=true \
MIX_ENV=prod \
mix phx.server
MIX_ENV=prod mix release
_build/prod/rel/phoenix_inertia_svelte/bin/phoenix_inertia_svelte start
Cause: The module wrapper issue - component isn't wrapped in { default: Component } format.
Solution: Ensure all pages in assets/js/app.js use the correct format:
const pages = {
Home: { default: Home }, // Correct
};
Cause: The page name from Phoenix doesn't match the key in your pages object.
Solution: Names are case-sensitive. If your controller calls render_inertia("About"), your pages object needs About: { default: About }.
Cause: Prop naming mismatch due to camelize_props setting.
Solution: Check config/config.exs. If camelize_props: true:
assign_prop(:user_name, "John")export let userName;Solution:
# Clear and rebuild
rm -rf priv/static/assets
mix assets.build
Solution:
cd assets
rm -rf node_modules
npm install
| Package | Version | Notes |
|---|---|---|
| Elixir | 1.14+ | Required |
| Erlang/OTP | 25+ | Required |
| Node.js | 18+ | Required |
| Phoenix | 1.8.x | Latest stable |
| @inertiajs/svelte | 2.x | Required for Svelte 5 |
| svelte | 5.x | Works with legacy and runes mode |
| esbuild-svelte | 0.9.x | Svelte 5 compatible |
| inertia (Elixir) | 2.x | Phoenix adapter |
MIT