From ios-from-web-guide
MANDATORY for any AsyncImage rendering a URL from a JSON response. Invoke before writing AsyncImage(url:) anywhere in the View layer.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ios-from-web-guide:swiftui-async-image-with-backend-pathsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
1. **Every image URL from the backend goes through `String.asBackendURL`.** Never pass a raw string to `URL(string:)` — if the backend returns `/uploads/abc.jpg`, `URL(string:)` silently returns a URL with no host, and `AsyncImage` displays the placeholder forever with no error.
AsyncImage with Backend PathsString.asBackendURL. Never pass a raw string to URL(string:) — if the backend returns /uploads/abc.jpg, URL(string:) silently returns a URL with no host, and AsyncImage displays the placeholder forever with no error.Extensions/String+BackendURL.swift on day 1. Before the first AsyncImage. Before the first feature. It's a 10-line extension that prevents 5 duplicate fullURL(_:) helpers from growing across the codebase.AsyncImage explicitly — .frame(maxWidth: .infinity).frame(height: X) or .aspectRatio(_:, contentMode:).frame(height: X).clipped(). Otherwise the natural image size bleeds up through the parent VStack (see swiftui-layout-pitfalls).AsyncImage(url:) { image in ... } placeholder: { Color.gray.opacity(0.15) } — so the frame is visually occupied during load.fullURL(_:) helper. Use .asBackendURL. This rule is enforced by hook H-W-4.AsyncImage(url:) call.// Backend responds:
// { "data": { "id": 123, "photo_url": "/uploads/abc.jpg" } }
// ❌ Classic silent failure
AsyncImage(url: URL(string: post.photoURL)) { $0.resizable() }
placeholder: { ProgressView() }
URL(string: "/uploads/abc.jpg") returns a URL with no scheme/host. AsyncImage dispatches a URLSession load against a hostless URL, gets an opaque failure, falls through to the placeholder, and reports nothing. The user sees a spinning ProgressView forever.
String.asBackendURL// Extensions/String+BackendURL.swift
extension String {
var asBackendURL: URL? {
if hasPrefix("http://") || hasPrefix("https://") {
return URL(string: self)
}
return URL(string: Configuration.apiBaseURL + self)
}
}
See <plugin-root>/templates/String+BackendURL.swift for the canonical version.
Now:
// ✅
AsyncImage(url: post.photoURL.asBackendURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.15)
}
.frame(height: 240)
.clipped()
https://api.example.com/uploads/abc.jpg..asBackendURL returns URL? which AsyncImage accepts directly.Every iOS project that consumes a web-first backend hits this inside the first 2-3 features. If you don't create String+BackendURL.swift on day 1, you'll write 5 different fullURL(_:) helpers — one per view file — and then spend a half-day consolidating them.
Signs you've waited too long:
fullURL, imageURL, resolveURL helpers scattered across views.struct FeedCardView: View {
let post: Post
var body: some View {
VStack(alignment: .leading, spacing: 8) {
AsyncImage(url: post.coverImageURL.asBackendURL) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
Color.gray.opacity(0.15)
}
.frame(maxWidth: .infinity)
.frame(height: 240)
.clipped()
.cornerRadius(12)
Text(post.title).font(.headline)
}
}
}
The simulator and device run identical code. The backend just happens to return different URL shapes in different environments — often signed S3 URLs in prod and /uploads/... in dev. .asBackendURL handles both.
That's the exact behavior .asBackendURL prevents. If you see this symptom anywhere, grep for URL(string: and replace with .asBackendURL.
URL(string:) in a View fileThis is the enforcement path. The hook scans files under Views/ and flags URL(string: calls that don't go through .asBackendURL. Fix by adopting the extension.
Usually a layout bug, not a URL bug — see swiftui-layout-pitfalls. But double-check the URL isn't being recomputed into nil somewhere.
An earlier, buggier version of the extension prepended the base URL unconditionally:
// ❌ Don't do this
var asBackendURL: URL? { URL(string: Configuration.apiBaseURL + self) }
This turns https://s3.amazonaws.com/foo.jpg into https://api.example.comhttps://s3.amazonaws.com/foo.jpg. Always check hasPrefix("http") first.
See <plugin-root>/templates/String+BackendURL.swift — copy into Extensions/String+BackendURL.swift on day 1.
ios-api-client-foundation — provides Configuration.apiBaseURL.swiftui-layout-pitfalls — AsyncImage frames and VStack/ScrollView interactions.ios-project-structure — where Extensions/ lives in the tree.Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub j-morgan6/ios-from-web-guide --plugin ios-from-web-guide