Swiftui async image with backend paths
Skill j-morgan6/iOS-from-web-guide/skills/swiftui-async-image-with-backend-paths
MANDATORY for any AsyncImage rendering a URL from a JSON response. Use before writing AsyncImage(url:) anywhere in the View layer.From its SKILL.md
npx -y skills add j-morgan6/iOS-from-web-guide --skill swiftui-async-image-with-backend-pathsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
5.6 KB, as published. Nobody here has run it
SwiftUI AsyncImage with Backend Paths
RULES — Follow these with no exceptions
- Every image URL from the backend goes through
String.asBackendURL. Never pass a raw string toURL(string:)— if the backend returns/uploads/abc.jpg,URL(string:)silently returns a URL with no host, andAsyncImagedisplays the placeholder forever with no error. - Create
Extensions/String+BackendURL.swifton day 1. Before the firstAsyncImage. Before the first feature. It's a 10-line extension that prevents 5 duplicatefullURL(_:)helpers from growing across the codebase. - Always frame
AsyncImageexplicitly —.frame(maxWidth: .infinity).frame(height: X)or.aspectRatio(_:, contentMode:).frame(height: X).clipped(). Otherwise the natural image size bleeds up through the parent VStack (seeswiftui-layout-pitfalls). - Use the three-argument form with a placeholder.
AsyncImage(url:) { image in ... } placeholder: { Color.gray.opacity(0.15) }— so the frame is visually occupied during load. - Never write a per-view
fullURL(_:)helper. Use.asBackendURL. RelativeURL(string: "/...")literals are flagged by theurl-relativehook after every write.
When to invoke
- Writing any
AsyncImage(url:)call. - Debugging "the photo never loads, just shows the placeholder forever".
- Setting up a new feature that displays remote images.
The silent failure
// 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.
The fix — 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()
- Relative paths become
https://api.example.com/uploads/abc.jpg. - Fully-qualified URLs (S3 signed URLs, CDN URLs) pass through unchanged.
- Nil safety:
.asBackendURLreturnsURL?whichAsyncImageaccepts directly.
The day-1 rule
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:
- Multiple
fullURL,imageURL,resolveURLhelpers scattered across views. - Some views show images; others silently don't.
- A new dev asks "why doesn't this AsyncImage work on the simulator?".
The full canonical pattern
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)
}
}
}
Common pitfalls
"It works on device with full URLs but not simulator with relative URLs"
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.
Nil URL → AsyncImage shows placeholder forever with no error
That's the exact behavior .asBackendURL prevents. If you see this symptom anywhere, grep for URL(string: and replace with .asBackendURL.
The url-relative hook flags a bare URL(string: "/...")
This is the enforcement path. The hook flags relative-path URL(string:) literals in any Swift file after the write. Fix by adopting the extension.
The image loads on first render, vanishes on scroll-back
Usually a layout bug, not a URL bug — see swiftui-layout-pitfalls. But double-check the URL isn't being recomputed into nil somewhere.
Fully-qualified URLs got "fixed" into broken ones
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.
Template reference
See <plugin-root>/templates/String+BackendURL.swift — copy into Extensions/String+BackendURL.swift on day 1.
Related skills
ios-api-client-foundation— providesConfiguration.apiBaseURL.swiftui-layout-pitfalls— AsyncImage frames and VStack/ScrollView interactions.ios-project-structure— whereExtensions/lives in the tree.