How to Build a REST API Client in SwiftUI
Create a Codable model, call
URLSession.shared.data(from:) inside an
async function, decode with
JSONDecoder, and drive your view with an
@Observable view model triggered by .task.
struct Post: Codable, Identifiable {
let id: Int
let title: String
}
func fetchPosts() async throws -> [Post] {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Post].self, from: data)
}
// In your SwiftUI view:
.task {
posts = try? await fetchPosts()
}
Full implementation
The pattern below separates concerns cleanly: a lightweight APIClient struct handles all network I/O and decoding, while an @Observable view model owns the UI state machine (idle → loading → loaded / error). The SwiftUI view stays declarative — it reacts to state changes without touching any URL logic. This structure makes unit-testing the client and view model trivial.
import SwiftUI
// MARK: - Models
struct Post: Codable, Identifiable {
let id: Int
let userId: Int
let title: String
let body: String
}
// MARK: - API Client
struct APIClient {
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared) {
self.session = session
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
}
func fetch<T: Decodable>(_ type: T.Type, from urlString: String) async throws -> T {
guard let url = URL(string: urlString) else {
throw URLError(.badURL)
}
let (data, response) = try await session.data(from: url)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try decoder.decode(T.self, from: data)
}
}
// MARK: - View Model
@Observable
final class PostsViewModel {
var posts: [Post] = []
var isLoading = false
var errorMessage: String?
private let client = APIClient()
func loadPosts() async {
isLoading = true
errorMessage = nil
do {
posts = try await client.fetch([Post].self,
from: "https://jsonplaceholder.typicode.com/posts")
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
// MARK: - Views
struct PostsView: View {
@State private var viewModel = PostsViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Loading posts…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = viewModel.errorMessage {
ContentUnavailableView(
"Failed to load",
systemImage: "wifi.exclamationmark",
description: Text(error)
)
} else {
List(viewModel.posts) { post in
NavigationLink(value: post) {
PostRowView(post: post)
}
}
.listStyle(.plain)
.navigationDestination(for: Post.self) { post in
PostDetailView(post: post)
}
}
}
.navigationTitle("Posts")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Refresh") {
Task { await viewModel.loadPosts() }
}
.disabled(viewModel.isLoading)
}
}
}
.task {
await viewModel.loadPosts()
}
}
}
struct PostRowView: View {
let post: Post
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(post.title)
.font(.headline)
.lineLimit(2)
Text("User \(post.userId)")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
}
}
struct PostDetailView: View {
let post: Post
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(post.title)
.font(.title2.bold())
Text(post.body)
.font(.body)
.foregroundStyle(.secondary)
}
.padding()
}
.navigationTitle("Post #\(post.id)")
.navigationBarTitleDisplayMode(.inline)
}
}
// MARK: - Preview
#Preview {
PostsView()
}
How it works
-
Generic
fetch<T: Decodable>— the single method onAPIClientaccepts anyDecodabletype, so you can reuse it for posts, users, comments, or any endpoint without boilerplate. -
HTTP status check — casting
responsetoHTTPURLResponseand validating the 200–299 range turns silent server errors (404, 500) into thrown errors that surface in the UI rather than producing bad decode results. -
@Observableview model — iOS 17's@Observablemacro replacesObservableObject+@Published. SwiftUI automatically tracks which stored properties the view reads and only re-renders when those change, eliminating unnecessary redraws. -
.taskmodifier — fires the async load when the view appears and automatically cancels it when the view disappears, preventing dangling network calls and memory leaks with zero extra code. -
ContentUnavailableView— the iOS 17 system view for empty or error states gives you a consistent, accessible UI pattern for free, including correct VoiceOver announcements without manual accessibility modifiers.
Variants
POST requests with a JSON body
extension APIClient {
func post<Body: Encodable, Response: Decodable>(
_ body: Body,
to urlString: String,
returning type: Response.Type
) async throws -> Response {
guard let url = URL(string: urlString) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse,
(200...299).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try decoder.decode(Response.self, from: data)
}
}
Adding an Authorization header
Store your bearer token in Keychain (never in UserDefaults) and inject it at the APIClient level by building a custom URLSessionConfiguration with httpAdditionalHeaders = ["Authorization": "Bearer \(token)"]. Passing the configured session into APIClient(session:) keeps all auth logic isolated and easily swappable in tests.
Common pitfalls
-
iOS 16 compatibility:
@ObservableandContentUnavailableVieware iOS 17-only. If you must support iOS 16, fall back toObservableObject+@Publishedand a custom empty-state view behind an#availablecheck. -
Main-actor state updates: Even though
URLSession.data(from:)resumes on a background thread,@Observableproperties mutated from an@MainActor-annotated view model are safe. If your view model is not@MainActor, annotate the state-mutation lines withawait MainActor.run { }to avoid data races caught by the Swift 6 concurrency checker. -
App Transport Security (ATS): HTTP (non-TLS) URLs are blocked by default on iOS. Use HTTPS endpoints, or add a scoped
NSExceptionDomainsentry inInfo.plistonly for local dev servers — never disable ATS globally in a shipping app. -
Decoding key mismatch: JSON keys are typically
snake_casewhile Swift properties arecamelCase. Setdecoder.keyDecodingStrategy = .convertFromSnakeCase(as shown above) rather than writing customCodingKeysenums for every model.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a REST API client in SwiftUI for iOS 17+. Use URLSession and Codable. Create a generic APIClient struct with fetch<T: Decodable> and post methods. Use @Observable for the view model with isLoading, errorMessage, and results state. Handle HTTP status errors distinctly from network errors. Make it accessible (VoiceOver labels, ContentUnavailableView for errors). Add a #Preview with realistic sample data.
In the Soarias Build phase, drop this prompt into the Implementation tab to scaffold the full networking layer — Soarias will wire it to your existing SwiftData models and generate matching unit test stubs automatically.
Related
FAQ
Does this work on iOS 16?
The URLSession + Codable networking layer works on iOS 16 and earlier. However, @Observable and ContentUnavailableView require iOS 17. Swap in ObservableObject + @Published and a bespoke empty-state view to target iOS 16.
How do I mock the API client for unit tests?
Extract a protocol (e.g., APIClientProtocol) with func fetch<T: Decodable>(...) async throws -> T, make APIClient conform to it, and inject a mock conformance in tests. Alternatively, pass a custom URLSession backed by URLProtocol stubs — Apple's recommended approach that lets you inject fixture JSON without touching production code.
What's the UIKit equivalent?
In UIKit you'd call URLSession.shared.dataTask(with:completionHandler:) (callback-based) or the same async/await data(from:) method inside a Task, then dispatch back to the main queue to update your UIViewController. The APIClient struct shown above is UIKit-compatible as-is — only the view layer changes.
Last reviewed: 2026-05-11 by the Soarias team.