Sample Elixir/Phoenix project with a setup tutorial to serve Svelte app via InertiaJS.
Includes Typescript. Includes TailwindCSS.
mix deps.getdev.exs file to add a valid PostgreSQL username, passwordmix ecto.createiex -S mix phx.serverDivided 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.gettest.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.servergit init && git add -A && git commit -m "Setup Elixir Phoenix"TailwindCSS 4 and Vite 7. So we will use Vite 6nodejs 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 frontendnpm 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/vitevite.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/sveltefrontend/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/pagesmv src/App.svelte src/pages/Home.svelteimport { 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 }
- return { default: page.default, layout: undefined }
},
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.exdefmodule 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 test1) 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/pagestests/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.