Phoenix Assets

A supervised SvelteKit toolchain and typed frontend contracts for Phoenix.

Hex.pm Docs CI 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.

Phoenix owns application behavior and authorization; the frontend consumes host-declared contracts. Generation validates declarations and their route wiring. It does not prove that a controller actually returns the declared payload; hosts need runtime contract tests for that. Queries, policies and the sync backend remain host-owned.

Sharing code across applications is not enough to place a feature here. Asset build/generation/supervision belongs here. The existing reporting and DocShell renderers are bounded adapters for upstream contracts; their presence does not make this a general UI or application-feature library. Runtime observation, browser pairing, source-edit workflows, business measurement and product layout belong to their host or dedicated contract/provider owners.

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.28, @tanstack/electric-db-collection 0.5.5 and @tanstack/svelte-db 0.5.4. 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}

Hosts using an external Electric server can omit the embedded 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.

The HTTP profile below is an unreleased source addition; published 1.2.1 does not include its preset, runtime entrypoint or DocShell 0.4 compatibility. HTTP/Channels-only applications can select the maintained non-Electric preset:

config :phoenix_assets, preset: PhoenixAssets.Presets.SvelteHttp, serve_mode: :spa

Use @phoenix-assets/svelte/core for commands/locales/events and /socket for Phoenix sockets. Electric and TanStack peers are optional; the legacy root runtime barrel still imports Electric helpers. DocShell 0.4 remains compatible with the asset substrate; its optional site renderer supports DocShell 0.5 and 1.0 presentation modules. Neither profile changes host authorization.

Tuning an integration uses configuration:

config :phoenix_assets, :dev, storybook: [enabled: false]   # run it via `mix storybook`
config :phoenix_assets, :stack, locales: ["sv", "en"], default_locale: "sv"

Configure the generated-contract alias with SvelteKit so the separate type check resolves the same modules as Vite. SvelteKit 3 reads Vite plugin options; recent 2.x versions support them too:

// assets/vite.config.ts
sveltekit({ alias: { $phoenix: "src/lib/generated" } })

Legacy 2.x hosts may retain kit.alias in svelte.config.js. SvelteKit 3 requires its matching adapters, Node 22.17+, Svelte 5.57.1+, TypeScript 6 and Vite 8.0.12+; follow the upstream migration guide.

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} />

Current source declares layerchart@2.9.1 as an optional peer. Reporting consumers install it explicitly; /core does not import reporting. The extracted HTTP consumer checks that unselected reporting and sync peers are absent. Installed hosts still need the exact-artifact upgrade qualification in RELEASING. 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.


Specifications

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.