Swift
Apple Inc.
The platform’s frontend has zero JavaScript. The entire UI is written in Embedded Swift, compiled to WebAssembly, and served as a static binary. No React, no Svelte, no build toolchain circus.
This is not the obvious choice. Here’s why I did it and what it’s actually like.
The Motivation
The backend is Swift/Vapor. The 11 open-source packages I maintain are all Swift. When I started building the frontend, I had two options:
Learn a JavaScript framework, maintain a second toolchain, and live with the impedance mismatch between Swift types on the backend and TypeScript types on the frontend
Write Swift for the frontend too
I chose option 2. The enabling technology is Embedded Swift — a subset of Swift designed for constrained environments (microcontrollers, WASM runtimes).
How It Works
Vapor renders a full HTML document on the server using Swift result builders — the same component library, the same types. The browser receives a complete page. No loading spinners, no skeleton screens. Then a WASM binary progressively hydrates the interactive parts.
sequenceDiagram
participant S as Vapor Server
participant B as Browser
participant W as WASM Binary
S->>B: 1. Full HTML Document (SSR)
Note over B: Initial Paint (Complete UI)
B->>S: 2. Fetch .wasm binary
S-->>B: Binary served
Note over B: Load WASM runtime
B->>W: 3. Invoke hydrateIslands()
W->>B: 4. Bind Events & State
Note over B: Interactive (Hydrated)Every component is split across two compilation targets using #if CLIENT. The server half renders HTML:
public struct AccordionView: HTMLContent {
public func render(indent: Int = 0) -> String {
div {
details {
summary {
h3 { titleContent }
AnimatedRightDownChevronView(
id: "accordion-\(id)",
expanded: open
)
}
div { contentSlot }
.class("accordion-content")
}
.open(open)
}
.class("accordion-view")
.render(indent: indent)
}
}The WASM half hydrates the server-rendered DOM — binding event listeners, managing animation state, dispatching custom events:
#if CLIENT
public class AccordionHydration: @unchecked Sendable {
public init() {
let allAccordions = document.querySelectorAll(
".accordion-view"
)
for accordion in allAccordions {
let instance = AccordionInstance(accordion: accordion)
instances.append(instance)
}
}
}
#endifThe hydration entry point checks which components exist on the current page and only hydrates those:
func hydrateIslands() {
hydrateCriticalComponents()
window.requestAnimationFrame {
hydrateSecondaryComponents()
}
}
private func hydrateCriticalComponents() {
if document.querySelector(".search-menu-view") != nil {
hydrateSearchMenu()
}
if document.querySelector(".navbar-view") != nil {
hydrateNavbarMobileMenu()
}
// ...
}This is island architecture with progressive hydration — critical interactive components like search and navigation hydrate synchronously on first frame, everything else defers to requestAnimationFrame. The page is readable and navigable before any WASM executes.
The Vertical Component Slice
In traditional web development, a single feature is often spread across an HTML file, a CSS file, and a JavaScript file. In this architecture, a component is a single vertical slice:
public struct ButtonView: HTMLContent {
public func render() -> Node {
button { label }
.class("btn-primary")
.style {
backgroundColor(colorBrand)
borderRadius(radiusMedium)
padding(spacing12, spacing24)
hover {
backgroundColor(colorBrandDark)
transform(.scale(1.05))
}
}
}
}This collocation of Markup (via HTMLBuilder), Styles (via CSSBuilder), and Logic (via WASM hydration) creates a component that is easy to reason about. If you need to change how a component behaves or looks, you stay in one file.
Swift “Server Components”
In this architecture, every component is a Server Component by default. Because the backend and frontend share the same Swift types, the server can render the initial state of any component with zero latency.
WASM is an opt-in “Hydration Island.” If a component doesn’t need interactivity—like a static header or a blog post body—it never touches the WASM runtime. This keeps the initial TTI (Time to Interactive) low while ensuring the page is functional even on slow connections.
Anatomy of a Component
Every interactive element follows a logical lifecycle. Let’s look at TableView.swift, which manages everything from massive data grids to client-side sorting:
1. The View (Shared)
The structural definition. This struct contains the render() method and reusable CSSBuilder blocks. Because it’s shared, it’s used by Vapor for SEO-perfect SSR and by WASM for dynamic DOM updates.
public struct TableView: HTMLContent {
let columns: [Column]
let data: [Row]
public func render() -> Node {
table {
thead { ... }
tbody { ... }
}
.class("table-view")
}
}
#if SERVER
extension TableView {
public init(caption: String, rows: [[String: String]]) {
// Convenience init for mapping DB results
self.init(captionContent: caption, data: rows.map { Row(cells: $0) })
}
}
#endif
Why the#if SERVERextension? In Embedded Swift, using[String: String]dictionaries pulls in massive Unicode normalization tables and hashing logic that can bloat the WASM binary by hundreds of kilobytes. By isolating “expressive” initializers to the server, we keep the client binary ultra-lean.
2. The Instance (Client)
The behavioral “soul” of the component. This WASM class manages the state, event listeners, and live DOM manipulations for a single element.
#if CLIENT
public class TableInstance {
let table: Element
public init(table: Element) {
self.table = table
setupSorting()
}
private func setupSorting() {
let buttons = table.querySelectorAll(".sort-button")
for button in buttons {
button.addEventListener(.click) { [weak self] _ in
self?.sort()
}
}
}
private func sort() {
// WASM logic for sorting the table rows in-place
}
}
#endif3. The Factory (Client)
The dynamic creator. A static Factory allows the WASM client to spawn new components in the browser on the fly—perfect for live-updating dashboards or infinite scroll.
#if CLIENT
public enum TableFactory {
public static func createElement(
columns: [TableView.Column],
data: [TableView.Row]
) -> Element {
let wrapper = document.createElement(.div)
let view = TableView(columns: columns, data: data)
wrapper.innerHTML = buildHTML { view.render() }
return wrapper.firstElementChild ?? wrapper
}
}
#endif4. The Hydration (Client)
The automation engine. A global class that scans the DOM on first load to “bring the page to life” by attaching instances to every server-rendered element it finds.
#if CLIENT
public class TableHydration {
public init() {
let allTables = document.querySelectorAll(".table-view")
for table in allTables {
_ = TableInstance(table: table)
}
}
}
#endifThe Packages I Built
Because this ecosystem doesn’t exist yet, I had to build the tooling:
admin-core— Core architecture for building interactive admin dashboardsdesign-tokens— Type-safe design system (Colors, Spacing, Typography)diff-engine— Efficient state diffing and DOM reconciliationembedded-swift-utilities— Low-level WASM interop and string helpersmarkdown-utilities— Safe and efficient Markdown rendering for Swift targetsweb-apis— Swift bindings for standard Browser APIs (DOM, Fetch, SSE)web-builders— Result builders for declarative HTML/CSSweb-components— 60 reusable UI components (Accordion, Dialog, Table, etc.)web-formats— Type-safe data formatting (Dates, Numbers, Markdown)web-security— Auth tokens, CSRF protection, and security headersweb-types— Exhaustive type-safe enums for the entire Web Spec
All of these are open source.
Why This Wins
Type safety across the full stack. The server renders a TableView. The WASM client hydrates TableInstance. Both are in the same WebComponents package, share the same DesignTokens, and reference the same WebTypes. If a shared primitive or token changes shape — a color token renamed, an enum case removed, a spacing value deleted — you get compile-time errors, not runtime visual drift.
Zero context switching. You stay in “Swift Mode” from the database to the DOM. No impedance mismatch between snake_case backend keys and camelCase JS logic. You use the same language, same brain, and same unit tests for the entire stack.
Unified design system. Since styles, markup, and logic live in one file, they are mathematically aligned. Change borderColorSubtle in your design system, and every component across both environments updates instantly.
Bundle size & performance. The compiled WASM binary is comparable to the gzipped payload of a complex, production-ready React application. The difference is that this single binary contains the entire component framework, design system, and a full WASM runtime with garbage collection. As browsers adopt native WASM-GC and standardized runtimes in the future, these internal overheads will vanish, and the binary size will plummet even further. No waterfall of lazy-loaded chunks, no third-party CDN dependencies. One request, cached, interactive.
No more “Build Tool Fatigue”. Swift Package Manager resolves your dependencies. No package.json, no node_modules, no Webpack/Vite configuration hell. Just swift build.
What’s Hard
Debugging. WASM debugging tools are improving but still rough compared to Chrome DevTools for JavaScript. Stack traces reference WASM offsets, not Swift line numbers.
Ecosystem. There’s no equivalent of npm for this. Every abstraction I needed, I had to write. This is why I ended up with 11 packages and over 60 components.
Hot reload. Doesn’t exist. Compile, refresh, repeat. A full rebuild of both server and client takes ~10 seconds. Incremental builds are faster but not instant.
Interop ceremony. Calling browser APIs from Swift requires writing bindings. The web-apis package handles the common ones, but if you need something exotic, you’re writing JavaScript glue code. Managing this coordination across multiple packages requires a unified loader strategy.
Behind the Scenes: The JavaScript Glue
WASM cannot talk to the DOM directly yet. The web-apis package bridges this by coordinating a Swift declaration with a JavaScript implementation in the loader:
// Swift side (web-apis)
@_extern(wasm, module: "env", name: "element_getOffsetHeight")
func js_getOffsetHeight(_ id: Int32) -> Double
extension Element {
public var offsetHeight: Double {
js_getOffsetHeight(id)
}
}// JavaScript side (loader.js)
const env = {
element_getOffsetHeight(id) {
const elem = getElement(id);
return elem ? elem.offsetHeight : 0;
}
}This “glue” is written once and abstracted away, giving you a pure Swift experience for 99% of your development.
Would I Do It Again?
Yes. The full-stack type safety eliminated an entire class of bugs. The SSR-first architecture means the site works without JavaScript. The component library I built is reusable across every Swift+WASM project I start next.
The upfront investment was high. But every component, every design token, every browser API binding compounds. The marginal cost of the next project is a fraction of this one.
Exploring the Ecosystem
If you’re interested in building for the web with Embedded Swift, don’t start from zero. These 11 packages are open source and designed to work together:
admin-core— Core logic and views for building admin consoles in Swift/WASM.design-tokens— A unified design system protocol for Swift targets.diff-engine— A high-performance text comparison engine for highlighting differences between strings.embedded-swift-utilities— Low-level interop and string helpers for WASM.markdown-utilities— Safe and efficient Markdown rendering for the web.web-apis— The bridge between Swift and the Browser DOM.web-components— 60+ UI components from accordions to multi-select lookups.web-builders— Result builders for declarative HTML/CSS.web-formats— Type-safe data formatting for the frontend.web-security— Auth tokens, CSRF protection, and CSP headers.web-types— Exhaustive type-safe enums for the entire Web Spec.
You can find all of these on GitHub under the Gnorium organization. They might just save you some months of work.