Phoenix Assets
A supervised SvelteKit toolchain and typed frontend contracts for Phoenix.
Phoenix applications with a JavaScript frontend have two build systems and one shared contract. Without a clear owner, route types get copied by hand, Storybook drifts from Vite, and frontend process failures disappear outside the BEAM.
phoenix_assets gives that boundary one owner. It supervises Vite and Storybook
under OTP, generates TypeScript from Phoenix and Ash metadata, and records the
result in an asset graph that can be checked before deployment. The default
PhoenixAssets.Presets.Svelte covers Vite, SvelteKit, Tailwind v4, Storybook,
ElectricSQL, PubSub, localization, commands, sessions, and Ash types.
The library has a narrow opinion: Phoenix owns application behavior and authorization; the frontend consumes generated contracts. It does not move Ash queries or policies into JavaScript, and it does not own the application's sync backend.
What it provides
- Routes, Ash resources, Electric shapes, commands, session data, PubSub topics,
and gettext locales generate into
$phoenix/*TypeScript modules.mix phoenix_assets.gen --checkcatches stale checked-in output. - Shape contracts describe reads. Command contracts describe request bodies, success values, and endpoint error codes. The session contract carries the authenticated context expected by the frontend.
- Routes, pages, stories, shapes, commands, topics, and locales share one
validated graph. Applications can read it from
graph.jsonor an embedded module. - Vite and Storybook run as OTP children. MuonTrap stops the OS process when the BEAM exits; Linux cgroups stop the process tree, while macOS stops the immediate child. Their status, logs, and restart behavior remain visible to the host.
- Generators produce identical bytes for identical inputs. If nothing changed, regeneration writes nothing and triggers no HMR.
mix phoenix_assets.doctor --productionchecks the manifest, package manager, generated contracts, and integration requirements.- Long-running work emits
:telemetryspans below[:phoenix_assets, ...], including:generated,:manifest,:dev_server, and:doctor.
How it fits together
PhoenixAssets.child_specs/0 always adds the manifest server. In development it
also adds a supervisor for Vite, Storybook, and the generated-file watcher.
Storybook reads the same Vite configuration as the application.
Tailwind v4 runs inside Vite through the official @tailwindcss/vite plugin.
The host owns src/app.css and its @theme; no JavaScript config is required. The
integration wires the plugin into the Vite config and contributes a doctor check
for the CSS entry. @phoenix-assets/lint flags arbitrary values such as
w-[180px] when the host design system provides a named equivalent such as
w-45. Its Svelte CLI accepts the standard module and instance script
composition. A host can opt into a single-script policy with glob exceptions.
The frontend imports generated contracts through $phoenix/* virtual modules.
When Elixir regenerates a contract, the Vite plugin invalidates the affected
modules and reloads them through HMR.
Packages
One Elixir package ships the runtime, the (internal) plugin engine, and the built-in Svelte stack. Four npm packages provide the Vite plugin, the Svelte runtime helpers, the documentation shell, and shared frontend lint tooling.
| Package | Path | What it is |
|---|---|---|
phoenix_assets |
lib/ |
Runtime, contract generators, dev supervision, manifest, graph, doctor, and the built-in SvelteKit, Tailwind, Storybook, ElectricSQL, command, session, PubSub, localization, Ash type, and typespec integrations. |
@phoenix-assets/vite |
npm/vite/ |
Vite plugin, $phoenix/* virtual modules, dev/HMR bridge, graph emitter. |
@phoenix-assets/svelte |
npm/svelte/ |
Typed Electric / PubSub / localization helpers plus the closed portable-report decoder, accessible tables, and shared LayerChart 2 components. |
@phoenix-assets/doc-shell |
npm/doc-shell/ |
Renderer-neutral Svelte documentation UI for the doc-shell/v1 artifact contract. |
@phoenix-assets/lint |
npm/lint/ |
Shared Biome base config, Svelte parser/policy linter, and Tailwind v4 arbitrary-value linter for host apps. |
Requirements
- Elixir 1.18+ and Phoenix 1.8+.
- Development supervision uses MuonTrap and requires macOS, Linux, or WSL2. Linux cgroups stop the whole process tree; macOS stops the immediate child. Production manifest serving and contract generation are platform-independent.
Usage
The Elixir package is published on Hex. Its four frontend packages are published on npm and run in the author's Phoenix applications.
The full Svelte stack is the default, so no preset module is required.
Install
# mix.exs
{:phoenix_assets, "~> 1.1.0"}
cd assets && pnpm add -D --save-exact @phoenix-assets/vite@1.1.0 @phoenix-assets/svelte@1.1.0 @phoenix-assets/lint@1.1.0
PostgreSQL 18 and Electric
The frontend is qualified with @electric-sql/client 1.5.27,
@tanstack/electric-db-collection 0.4.7 and @tanstack/svelte-db 0.3.7.
Install the TanStack peers when using the /collection entry point.
Your application owns the sync backend. For the qualified PostgreSQL 18.6 / Electric 1.8.1 embedded stack, declare these host dependencies:
{:phoenix_sync,
github: "futhr/phoenix_sync",
ref: "330f0602009b7b8aca7e3140b492b5408ee276ec"},
{:electric, "~> 1.8.1", override: true}
HTTP-only hosts can omit the Electric dependency and connect to the qualified external server. The Phoenix Assets Hex package does not constrain either backend dependency. Keep Phoenix.Sync's sandbox adapter in tests that use embedded sync, and configure PostgreSQL logical replication for the server.
Configure & supervise
# config/config.exs
config :phoenix_assets,
otp_app: :my_app,
endpoint: MyAppWeb.Endpoint,
router: MyAppWeb.Router
config :phoenix_assets, :stack,
shapes: MyApp.Assets.ElectricShapes,
commands: MyApp.Assets.Commands,
session: MyApp.Assets.Session,
topics: MyApp.Assets.PubSubTopics,
types: MyApp.Assets.Types
# config/dev.exs: supervise Vite, Storybook, and the generated-file watcher
config :phoenix_assets, :dev, enabled: true
Add the runtime to your supervision tree. child_specs/0 always returns the
manifest server and adds the dev supervisor in development:
children = [...] ++ PhoenixAssets.child_specs()
:otp_app is the only required option; the full reference is PhoenixAssets.Config.
Sub-configs: :dev, :build (vite_manifest, asset_graph, asset_url,
budgets, allow_source_maps), :env (expose:), :dev_intelligence
(tidewave:), and :stack. serve_mode defaults to :spa, an adapter-static
SvelteKit build that serves its own index.html. Set :ssr to render HTML from
the Vite manifest through PhoenixAssets.Components.
Tuning an integration is config, not a reason to write a preset:
config :phoenix_assets, :dev, storybook: [enabled: false] # run it via `mix storybook`
config :phoenix_assets, :stack, locales: ["sv", "en"], default_locale: "sv"
Point svelte-check at the generated contracts. It does not run through Vite,
so it needs the alias that the plugin resolves at build time:
// assets/svelte.config.js
kit: { alias: { $phoenix: "src/lib/generated" } }
Declare & generate contracts
Declare the metadata the frontend needs. Ash queries, policies, tenancy, and other application behavior stay in the host:
defmodule MyApp.Assets.ElectricShapes do
use PhoenixAssets.Electric.Shapes
shape :articles, route: "/shapes/articles", type: "Article"
end
defmodule MyApp.Assets.PubSubTopics do
use PhoenixAssets.PubSub.Topics
topic :room, pattern: "room:{id}", events: [message: "Message"]
end
defmodule MyApp.Assets.Types do
use PhoenixAssets.Types.Schema
type "Article", resource: MyApp.Blog.Article, only: :public
end
mix phoenix_assets.gen # write assets/src/generated/*
mix phoenix_assets.gen --check # CI drift gate; fails when the checked-in output is stale
Import the typed output through $phoenix/* virtual modules. Sensitive and
non-public Ash fields are excluded from generated types unless the host exposes
them explicitly. Page routes remain owned by SvelteKit:
import { routes } from "$phoenix/routes"
import type { Article } from "$phoenix/types"
import { shapes } from "$phoenix/electric"
Portable report consumers import only the shared reporting boundary. It accepts the bounded renderer-neutral contract, rejects unknown or renderer-specific configuration, validates kind-specific channels and every field reference, and always preserves a semantic table representation. String and object callers are held to the same finite, acyclic JSON-only byte and nesting limits:
<script lang="ts">
import { decodeReportEnvelope, PortableReport } from "@phoenix-assets/svelte/reporting"
let { payload } = $props()
const envelope = decodeReportEnvelope(payload)
</script>
<PortableReport {envelope} />
layerchart is an exact internal dependency of this subpath. Host applications
pass the portable report contract across storage or network boundaries, not
LayerChart options. Hosts supply semantic CSS tokens, localized chrome, and
domain evidence around the shared components.
Render assets
<PhoenixAssets.Components.vite_assets entry="src/app.ts" nonce={@csp_nonce} />
<PhoenixAssets.Components.svelte_page name="Dashboard" props={%{user: @user}} />
In development these point at the Vite dev server. In production they emit the
hashed file with its stylesheet links, module preloads, and Subresource Integrity
from the manifest. Components.speculation_rules/1 emits a Speculation
Rules prefetch block for the page routes in the asset graph. Set
config :phoenix_assets, :build, asset_url: for a CDN, or add
plug PhoenixAssets.EarlyHints, entry: "src/app.ts" for HTTP 103 Early Hints.
Ship
Wire the drift gate and the production doctor into your deploy alias:
# mix.exs
"assets.deploy": ["phoenix_assets.gen --check", "phoenix_assets.doctor --production", ...]
doctor --production validates the manifest, contract freshness, bundle budgets,
source-map leakage, and that every plugin initialises. Every long-running
operation emits :telemetry under [:phoenix_assets, ...]; see
PhoenixAssets.Telemetry.
A different stack
Write a module with use PhoenixAssets.Preset, list your integration/2 calls,
and set it as :preset (copy PhoenixAssets.Presets.Svelte as a starting point).
Add an integration the stack doesn't ship by writing a use PhoenixAssets.Plugin
module.
Contributing
See CONTRIBUTING.md for setup, conventions, and quality gates. The coordinated Hex/npm release process is documented in RELEASING.md.
Fleet library lockstep
Changes to phoenix_assets, ash_oaskit, or doc_shell are validated across
all consuming fleet platforms. A version bump moves consumers together, and any
consumer override: pin is updated in the same change.
License
Phoenix Assets is released under the MIT License. See LICENSE for details.