Phoenix Assets

Supervised SvelteKit tooling and typed frontend contracts for Phoenix.

Hex.pmDocsCICoverageLicense


Phoenix and a real JS frontend usually grow into two apps that barely know each other: Vite runs unsupervised, Storybook drifts out of sync, the contracts between backend and frontend get hand-copied, and an error on one side is invisible to the other. phoenix_assets closes those seams. It supervises Vite and Storybook as real children of your app, generates typed TypeScript from your routes, Ash resources, Electric shapes, PubSub topics, and locales, and links the whole thing into one asset graph it can validate before you ship.

It is unapologetically opinionated, and that's the point. Instead of trying to support every framework under the sun, it commits to one stack and wires it together so there's nothing left to assemble. That's the exact stack every one of my Phoenix platforms runs — so it's built for those first, and it's MIT and yours if you run the same one.

Phoenix lifecycle, routes, auth, generated contracts, dev supervision, the asset graph
Vite JS / TS / Svelte compilation, HMR, production bundles
SvelteKit components, hydration, client routing
Storybook isolated component development (shares Vite's config)
phoenix_assets ties it together — one graph, one supervisor, one set of contracts

All composed into the default PhoenixAssets.Presets.Svelte.


Depth, not just a bundler

A modern asset pipeline isn't "we run a bundler." The baseline the JavaScript world expects — Laravel Vite, vite-ruby, Vite itself — is a pipeline that reads a build manifest, emits correct hashed <script>/<link> tags with the production niceties (CSP nonces, Subresource Integrity, module preloading), and keeps dev and prod in sync. phoenix_assets does all of that, and goes deeper — because Phoenix knows things a PHP or Ruby app never will at build time:

That's the difference between wiring a bundler into a framework and making the frontend observable and type-checked from the Phoenix application.


How it fits together

Vite, SvelteKit, and Storybook are supervised as one unit.PhoenixAssets.child_specs/0 adds the manifest server (always) and, in development, a supervisor that owns Vite, Storybook, and the generated-file watcher. Storybook shares Vite's config, so the two never drift.

Tailwind v4 runs inside Vite through the official @tailwindcss/vite plugin — CSS-first, no JS config; you own src/app.css and its @theme. The integration wires the plugin into the Vite config and contributes a doctor check that the CSS entry exists, so the asset graph stays honest about what produces your CSS. @phoenix-assets/lint adds a Tailwind v4 linter that flags arbitrary values like w-[180px] when a named equivalent (w-45) exists — checked against your real design system.

The frontend imports generated contracts directly through $phoenix/* virtual modules, and HMR is bridged: when Elixir regenerates a contract, the Vite plugin invalidates the affected modules and reloads — no manual restart, no stale types.


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.

PackagePathWhat it is
phoenix_assetslib/Runtime + generated-contracts engine, dev supervision, manifest, graph, doctor, and the built-in SvelteKit + Tailwind + Storybook + ElectricSQL + commands + session + PubSub + localization + Ash-types + typespec stack.
@phoenix-assets/vitenpm/vite/Vite plugin, $phoenix/* virtual modules, dev/HMR bridge, graph emitter.
@phoenix-assets/sveltenpm/svelte/Typed Electric / PubSub / localization helpers plus the closed portable-report decoder, accessible tables, and shared LayerChart 2 components.
@phoenix-assets/doc-shellnpm/doc-shell/Renderer-neutral Svelte documentation UI for the doc-shell/v1 artifact contract.
@phoenix-assets/lintnpm/lint/Shared Biome base config + Tailwind v4 arbitrary-value linter for host apps.

Requirements


Usage

Runs in production on the author's platforms. Packaged for Hex, but not published there yet — install it from GitHub.

The full Svelte stack is the default — there's no preset module to write.

Install

# mix.exs
{:phoenix_assets, github: "futhr/phoenix-assets"}
cd assets && pnpm add -D @phoenix-assets/vite @phoenix-assets/svelte @phoenix-assets/lint

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 returns the manifest server always, plus 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 instead.

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 the plugin resolves at build time:

// assets/svelte.config.js
kit: { alias: { $phoenix: "src/lib/generated" } }

Declare & generate contracts

Declare your backend contracts — metadata only; the real work (Ash queries, policies, tenancy) stays in your controllers:

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 never reach a generated type, and page routes are SvelteKit's — never generated:

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 internal exact dependency of this subpath. Host applications must not pass LayerChart options through storage/network data or install another generic chart stack for portable report kinds. 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

Contributions are welcome! Please see CONTRIBUTING.md for guidelines. 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.