Assets
Add images, video, audio, fonts, and animations to a paywall by importing files. The build handles optimization, hosting, and caching.
Add media to a paywall by importing files from an assets/ directory. The build handles optimization, hosting, and caching; there is nothing to configure and no upload step.
Where assets live
Assets follow the same two-level pattern as components and messages: shared at the project root, local inside a paywall.
superwall/
├── assets/ shared across every paywall
└── paywalls/pro/
└── assets/ this paywall's ownEvery asset belongs in an assets/ directory, superwall/assets/ for shared files, superwall/paywalls/<id>/assets/ for one paywall's own. If a large asset lives anywhere else, the build fails and names the file.
Use an image
Import the file and use it like any URL:
import hero from "@/assets/hero.jpg"; // shared: superwall/assets/
import badge from "../assets/badge.png"; // this paywall's own
<img src={hero} alt="" />CSS url() works the same way. Imports typecheck because of the generated superwall.d.ts, one more reason to commit it.
Supported out of the box:
| Kind | Formats |
|---|---|
| Images | png jpg jpeg webp avif gif svg ico apng |
| Video | mp4 webm mov m4v |
| Audio | mp3 m4a aac wav ogg |
| Fonts | woff2 woff ttf otf |
| Animation & 3D | lottie riv glb |
?url, ?raw, and ?inline import suffixes work too, as do CSS modules.
How hosting works
You never choose where an asset is served from. The build decides, and nothing about your code changes either way:
- Video, audio, and fonts are always served from Superwall's CDN, whatever their size. Video streams properly instead of being carried by the paywall, and one upload is reused across every version of every paywall.
- Images embed in the paywall when small and move to the CDN when large.
import promo from "../assets/promo.mp4";
<video src={promo} autoPlay muted loop playsInline />Custom fonts
A relative-path @font-face is the whole setup:
@font-face {
font-family: "Manrope Custom";
font-display: swap;
font-weight: 200 800;
src: url("../assets/manrope-latin.woff2") format("woff2-variations");
}
:root { --sans: "Manrope Custom", ui-sans-serif, system-ui, sans-serif; }Google Fonts go in a CSS @import at the top of the stylesheet, never a React-rendered <link> tag. The stylesheet ships in the page itself, so the browser finds the @import immediately; a rendered <link> waits for JavaScript to run first, and the text flashes.
The custom-fonts example shows a local file and a Google Fonts import side by side.
Lottie
Two ways to ship a Lottie animation, with different trade-offs:
// 1. Animation JSON — embedded in the paywall. Offline-proof, zero requests.
// Best for small animations.
import spinner from "@/assets/spinner.json";
// 2. A .lottie file — served from the CDN and pre-cached on device by the
// SDK before the paywall opens. Best for bigger animations.
import intro from "@/assets/intro.lottie";Rive
.riv files load like any asset, plus one required setup step:
import { useRive, RuntimeLoader } from "@rive-app/react-canvas";
import riveWasm from "@rive-app/canvas/rive.wasm?url";
import smiley from "../assets/smiley.riv";
RuntimeLoader.setWasmUrl(riveWasm);
RuntimeLoader.setWasmFallbackUrl(null);
const { RiveComponent } = useRive({ src: smiley, stateMachines: "State Machine 1", autoplay: true });Rive fetches its WebAssembly engine from a CDN by default, and a published paywall's CSP allows connect-src only to Superwall's own origins, so that fetch never lands. Bundle the wasm with the ?url import as above, and null the fallback so a failure stays loud rather than silently retrying a CDN that will never answer. (Stylesheets, fonts, images and media from https: are fine, which is why the Google Fonts @import above works.)
Also pass the file's real state-machine name, naming one that doesn't exist leaves a blank canvas and no error. The with-rive example is the reference.
Multi-page flows
Nothing to do, while the user is on the current page, the next pages' images, video, and fonts warm automatically. Every hosted asset a version references is also stamped into the paywall's pre-cache manifest on promote, so the SDK caches it on device before the paywall opens.
Local resources
A large video or image the app already ships doesn't need to come over the network. The host app registers it under an id (on iOS, options.localResources["hero-video"] = Bundle.main.url(forResource: "hero", withExtension: "mp4")! before configure()), and the SDK serves it to the paywall at swlocal://hero-video, reporting the registered ids as a device variable. The paywall asks for the id and keeps a fallback for everywhere else:
import { useLocalResource } from "superwall/hooks";
import hero from "../assets/hero.mp4";
const src = useLocalResource("hero-video", hero);
<video src={src} autoPlay muted loop playsInline />On a device that registered hero-video this loads from the bundle; on the web, in the studio, or on an SDK that predates local resources it loads the imported asset. The ids are whatever the app chose, agree on them with the app side. See the iOS local resources guide for registering them.
Keep it light
- Big imagery is fine. It's served from the CDN and cached, not carried by the paywall itself.
- Pushing files over 50 MB warns (every future clone of the source pays for them), but nothing is capped.
How is this guide?