Sample Elixir/Phoenix project with a setup tutorial to serve Svelte app via InertiaJS.
Includes Typescript. Includes TailwindCSS.
mix deps.get
dev.exs
file to add a valid PostgreSQL username
, password
mix ecto.create
iex -S mix phx.server
Divided into the following stages
NOTE: Every stage from the second onwards has a PR associated with it. The stage will have a link to the PR so you can see the changes made. Your changes should look something similar to the PRs.
elixir 1.18.4-otp-27
so make sure you have that or something close enoughmix phx.new phx_inertia_svelte_ts_tw
to create an Elixir/Phoenix project(replace phx_inertia_svelte_ts_tw as you desire)phx_inertia_svelte_ts_tw
with the name you usedmix deps.get
test.exs
and add a valid username
and password
(usually, on MacOS username="<your-login-username>"
and password=""
)mix test
. All your tests should passdev.exs
and add a valid username
and password
(usually, on MacOS username="<your-login-username>"
and password=""
)mix ecto.create
to create the PostgreSQL databaseiex -S mix phx.server
git init && git add -A && git commit -m "Setup Elixir Phoenix"
TailwindCSS 4
and Vite 7
. So we will use Vite 6
nodejs 22.12.0
so make sure you have something close to thatfrontend/
directory instead of assets/
like how most Phoenix projects do itnpm create vite@6 frontend -- --template svelte-ts
in your phoenix root directorycd frontend
npm install
to install the dependenciesnpm i --save-dev @types/node
because typescript projects usually need some node type definitionsnpm run dev
. (Watch the port it listens on. Use that port in the next step if it is different from 5174)Vite + Svelte
page with a basic counter.git add -A && git commit -m "Setup Vite Svelte"
frontend/
directory, run npm install tailwindcss @tailwindcss/vite
vite.config.ts
andimport tailwindcss from "@tailwindcss/vite";
tailwindcss()
to the plugins field- plugins: [svelte()],
+ plugins: [svelte(), tailwindcss()],
frontend/app.css
, replace the whole file with just @import "tailwindcss";
frontend/src/App.svelte
content to<script lang="ts">
let lineNumber = $state(32);
</script>
<main>
<div class="max-w-md bg-white mx-auto my-20 p-5 shadow-xl">
<div class="text-red-500">Roses are red</div>
<div class="text-violet-500">Violets are blue</div>
<div class="text-gray-600">Cannot Read Property of Undefined</div>
<div class="text-gray-600">
On <span class="underline">line {lineNumber}</span>
</div>
</div>
</main>
npm run dev
in frontend/
directory{:inertia, "~> 2.5.1"},
to your mix.exs depsmix deps.get
to get the dependencies.config :inertia,
endpoint: PhxInertiaSvelteTsTwWeb.Endpoint,
static_paths: ["/assets/app.js"],
default_version: "1",
camelize_props: false,
history: [encrypt: false],
ssr: false,
raise_on_ssr_failure: config_env() != :prod
phx_inertia_svelte_ts_tw_web.ex
file, in the def controller do ... end
block add this line after the use Phoenix.Controller, ...
statementuse Phoenix.Controller,
formats: [:html, :json],
layouts: [html: PhxInertiaSvelteTsTwWeb.Layouts]
+ import Inertia.Controller
def html do ... end
block add this line after the import Phoenix.Controller, ...
statementimport Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
+ import Inertia.HTML
router.ex
file add the Inertia.Plug to at the end of the :browser
pipeline
```diff
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {PhxInertiaSvelteTsTwWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers- <.live_title default="PhxInertiaSvelteTsTw" suffix=" ยท Phoenix Framework">
- {assigns[:page_title]}
- </.live_title>
- <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
- <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
+ <.inertia_title>{assigns[:page_title]}</.inertia_title>
+ <.inertia_head content={@inertia_head} />
+ <link phx-track-static rel="stylesheet" href={~p"/assets/main.css"} />
+ <script defer phx-track-static type="text/javascript" src={~p"/assets/main.js"}>
app.html.heex
with the following line{@inner_content}
defmodule PhxInertiaSvelteTsTwWeb.PageController do
use PhxInertiaSvelteTsTwWeb, :controller
def home(conn, _params) do
conn
|> assign_prop(:title, "Welcome to the home page")
|> render_inertia("Home")
end
end
dev.exs
we want to add our Vite watcher. So replace the watchers field withwatchers: [
esbuild:
{Esbuild, :install_and_run, [:phx_inertia_svelte_ts_tw, ~w(--sourcemap=inline --watch)]},
- tailwind: {Tailwind, :install_and_run, [:phx_inertia_svelte_ts_tw, ~w(--watch)]}
+ tailwind: {Tailwind, :install_and_run, [:phx_inertia_svelte_ts_tw, ~w(--watch)]},
+ npx: [
+ "vite",
+ "build",
+ "--mode",
+ "development",
+ "--watch",
+ "--config",
+ "vite.config.js",
+ cd: Path.expand("../frontend", __DIR__)
+ ]
]
"assets.setup", "assets.build", "assets.deploy"
tasks in your mix.exs with- "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
+ "assets.setup": [
+ "tailwind.install --if-missing",
+ "esbuild.install --if-missing",
+ "cmd --cd frontend npm install"
+ ],
- "assets.build": ["tailwind phx_inertia_svelte_ts_tw", "esbuild phx_inertia_svelte_ts_tw"],
+ "assets.build": [
+ "tailwind phx_inertia_svelte_ts_tw",
+ "esbuild phx_inertia_svelte_ts_tw",
+ "cmd --cd frontend npx vite build --config vite.config.js"
+ ],
"assets.deploy": [
"tailwind phx_inertia_svelte_ts_tw --minify",
"esbuild phx_inertia_svelte_ts_tw --minify",
+ "cmd --cd frontend npx vite build --mode production --config vite.config.js",
"phx.digest"
]
defmodule PhxInertiaSvelteTsTwWeb.PageControllerTest do
use PhxInertiaSvelteTsTwWeb.ConnCase
import Inertia.Testing
describe "GET /" do
test "renders the home page", %{conn: conn} do
conn = get(conn, "/")
assert inertia_component(conn) == "Home"
page_props = inertia_props(conn)
assert %{
# from home() controller props
title: "Welcome to the home page"
} = page_props
end
end
end
mix test
and our test should pass!frontend/
directorynpm install @inertiajs/svelte
frontend/main.ts
file withimport { createInertiaApp, type ResolvedComponent } from "@inertiajs/svelte";
import { mount } from "svelte";
import "./app.css";
createInertiaApp({
resolve: (name) => {
const pages: Record<string, ResolvedComponent> = import.meta.glob(
"./pages/**/*.svelte",
{ eager: true }
);
let page = pages[`./pages/${name}.svelte`];
return { default: page.default, layout: undefined }
},
setup({ el, App, props }) {
if (el) {
mount(App, { target: el, props });
}
},
});
frontend/src/pages
so with frontend/
as the current directory, run mkdir -p src/pages
mv src/App.svelte src/pages/Home.svelte
import { loadEnv, defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
return {
publicDir: false,
plugins: [tailwindcss(), svelte()],
build: {
outDir: "../priv/static",
target: ["es2022"],
rollupOptions: {
input: "src/main.ts",
output: {
assetFileNames: "assets/[name][extname]",
chunkFileNames: "[name].js",
entryFileNames: "assets/[name].js",
},
},
commonjsOptions: {
exclude: [],
// include: []
},
},
define: {
__APP_ENV__: env.APP_ENV,
},
};
});
index.html
as InertiaJS will inject the Svelte component into root.html.heexiex -S mix phx.server
in your phoenix root directory.PR Link: Setup persistent layouts
Now lets add some more sample pages and wrap some of them in a layout.
We will add a Layout.svelte file and configure inertiaJS to use it as a persistent layout for some pages
Add a Layout.svelte
in frontend/src/layouts
with these contents
<script lang="ts">
import { page } from "@inertiajs/svelte";
// `children` is passed by inertiaJS
// `title` is passed from our controller functions' assign_prop
let { children, title } = $props();
// get the page url from inertiaJS page store
let currentPageUrl = $state<string | null>(null);
page.subscribe((page) => {
currentPageUrl = page.url;
});
// function to underline(show active) the link for current route
function pageClasses(url: string) {
return `text-white text-base ${currentPageUrl === url ? "underline" : ""}`;
}
</script>
<svelte:head><title>{title ?? "Page Title"}</title></svelte:head>
<main class="h-screen overflow-scroll bg-gray-200">
<nav class="bg-gray-800 p-4 flex justify-between items-center">
<div class="flex gap-3 items-end">
<div class="text-gray-100 text-lg font-semibold">Sinph</div>
<a href="/counter" class={pageClasses("/counter")}>Counter</a>
<a href="/todos" class={pageClasses("/todos")}>Todos</a>
</div>
<div class="flex items-center space-x-4">
<span class="text-white">TODO - username</span>
<a
href="/"
class="bg-gray-200 hover:bg-gray-100 text-gray-800 px-2 py-1 rounded text-sm"
>Logout</a
>
</div>
</nav>
<article>
{@render children()}
</article>
</main>
Note the usage of the InertiaJS <Link>
component. For more details visit https://inertiajs.com/links
Replace your main.ts file with
import { createInertiaApp, type ResolvedComponent } from "@inertiajs/svelte" ;
import { mount } from "svelte";
import "./app.css";
+ import Layout from "./layouts/Layout.svelte";
+ // In case you want some pages without layout: "Login","Register" etc
+ const NO_LAYOUT_ROUTES = ["Login"];
createInertiaApp({
resolve: (name) => {
const pages: Record<string, ResolvedComponent> = import.meta.glob(
"./pages/**/*.svelte",
{ eager: true }
);
let page = pages[`./pages/${name}.svelte `];
+ let layout = (NO_LAYOUT_ROUTES.includes (name))
? undefined : Layout as unknown as ResolvedComponent["layout"];
- return { default: page.default, layout: undefined }
+ return { default: page.default, layout }
},
setup({ el, App, props }) {
if (el) {
mount(App, { target: el, props });
}
},
});
Now lets add some simple Login, Counter and Todos pages. The content is a bit bigger than what I would like to paste here, so each step below has the link to the page commited
frontend/src/pages/Login.svelte
with the contents from simple Login.sveltefrontend/src/pages/Counter.svelte
with the contents from simple Counter.sveltefrontend/src/pages/Todos.svelte
with the contents from simple Todos.svelteNow lets add controller functions to serve these pages.
Replace lib/my_phx_svelte_app_web/controllers/page_controller.ex with
defmodule PhxInertiaSvelteTsTwWeb.PageController do
use PhxInertiaSvelteTsTwWeb, :controller
def login(conn, _params) do
conn
|> assign_prop(:title, "Welcome to the login page")
|> render_inertia("Login")
end
def counter(conn, _params) do
conn
|> assign_prop(:title, "A simple svelte counter")
|> render_inertia("Counter")
end
def todos(conn, _params) do
conn
|> assign_prop(:title, "A simple svelte todo app")
|> render_inertia("Todos")
end
end
Change the routes in router.ex with
- get "/", PageController, :home
+ get "/", PageController, :login
+ get "/counter", PageController, :counter
+ get "/todos", PageController, :todos
Run iex -S mix phx.server
Visit http://localhost:4000 and check that a navbar is visible only in the http://localhost:4000/counter and http://localhost:4000/todos page and the current link is underlined in the navbar
At the end of this stage your changes should look something like this PR: Setup persistent layouts
mkdir lib/phx_inertia_svelte_ts_tw_web/plugs/
and create a file lib/my_phx_svelte_app_web/plugs/dummy_user_auth.ex
defmodule PhxInertiaSvelteTsTwWeb.DummyUserAuthPlug do
import Inertia.Controller
import Plug.Conn
def init(opts) do
opts
end
def call(conn, opts) do
dummy_authenticate_user(conn, opts)
end
defp dummy_authenticate_user(conn, _opts) do
user = %{"email" => "[email protected]", "name" => "Ook Oook"}
# Here we are storing the user in the conn assigns (so
# we can use it for things like checking permissions later on),
# AND we are assigning a serialized represention of the user
# to our Inertia props.
conn
# for other controllers etc
|> assign(:current_user, user)
# for inertia page props
|> assign_prop(:me, user)
end
end
:browser
pipeline in our router.ex
plug :put_secure_browser_headers
plug Inertia.Plug
+ plug PhxInertiaSvelteTsTwWeb.DummyUserAuthPlug
defmodule PhxInertiaSvelteTsTwWeb.PageControllerTest do
use PhxInertiaSvelteTsTwWeb.ConnCase, async: true
import Inertia.Testing
describe "GET /" do
test "renders the home page", %{conn: conn} do
conn = get(conn, "/")
assert inertia_component(conn) == "Login"
page_props = inertia_props(conn)
assert %{
# from shared props
me: %{email: "[email protected]", name: "Ook Oook"},
# from login() controller props
title: "Welcome to the login page"
} = page_props
end
end
end
mix test
1) test GET / renders the home page .../controllers/page_controller_test.exs:7
...
left: %{me: %{email: "[email protected]", name: "Ook Oook"}, title: "Welcome to the login page"}
right: %{me: %{"email" => "[email protected]", "name" => "Ook Oook"}, title: "Welcome to the login page", errors: %{}, flash: %{}}
...
- user = %{"email" => "[email protected]", "name" => "Ook Oook"}
+ user = %{email: "[email protected]", name: "Ook Oook"}
frontend/
directorynpm install --save-dev @testing-library/svelte @testing-library/jest-dom jsdom vitest
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ },
+ resolve: {
+ conditions: mode === 'test' ? ['browser'] : [],
+ },
mkdir -p tests/pages
tests/pages/login.test.ts
with these contentsimport { describe, it, expect } from 'vitest';
import '@testing-library/jest-dom';
import { render } from '@testing-library/svelte';
import Login from '../../src/pages/Login.svelte';
describe('Login Page', () => {
it('renders the Login page with card title', () => {
const result = render(Login);
const headerText = result.getByText('Sign in to your account');
expect(headerText).toBeInTheDocument();
});
});
tests/pages/counter.test.ts
with the contents asimport { describe, it, expect } from 'vitest';
import '@testing-library/jest-dom';
import { render } from '@testing-library/svelte';
import Counter from '../../src/pages/Counter.svelte';
describe('Counter Page', () => {
it('renders the counter page with count and buttons', () => {
const result = render(Counter);
const countText = result.getByText('0');
expect(countText).toBeInTheDocument();
});
});
tests/pages/todos.test.ts
with the contents asimport { describe, it, expect } from 'vitest';
import '@testing-library/jest-dom';
import { render } from '@testing-library/svelte';
import Todos from '../../src/pages/Todos.svelte';
describe('Todos Page', () => {
it('renders the counter page with count and buttons', () => {
const result = render(Todos);
const countText = result.getByText('Complete svelte tutorial');
expect(countText).toBeInTheDocument();
});
});
package.json
file- "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
+ "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
+ "test:unit": "vitest",
+ "test": "npm run test:unit -- --run"
npm run test
โ tests/pages/counter.test.ts (1 test) 17ms
โ tests/pages/login.test.ts (1 test) 20ms
โ tests/pages/todos.test.ts (1 test) 24ms
Test Files 3 passed (3)
Tests 3 passed (3)
mix deps.unlock esbuild tailwind heroicons
in your phoenix root directory(where the mix.exs file is located)esbuild
, tailwind
, heroicons
deps from mix.exsconfig :esbuild, ...
block from config.exsconfig :tailwind, ...
block from config.exsassets.setup
, assets.build
and assets.deploy
in mix.exs./assets
directory because I don't use anything from it.