Phoenix Assets

A supervised SvelteKit toolchain and typed frontend contracts for Phoenix.

Hex.pm Docs CI Coverage License

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

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

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.