```html SwiftUI: How to Build a REST API Client (iOS 17+, 2026)

How to Build a REST API Client in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: URLSession / Codable Updated: May 11, 2026
TL;DR

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

  1. Generic fetch<T: Decodable> — the single method on APIClient accepts any Decodable type, so you can reuse it for posts, users, comments, or any endpoint without boilerplate.
  2. HTTP status check — casting response to HTTPURLResponse and 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.
  3. @Observable view model — iOS 17's @Observable macro replaces ObservableObject + @Published. SwiftUI automatically tracks which stored properties the view reads and only re-renders when those change, eliminating unnecessary redraws.
  4. .task modifier — 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.
  5. 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

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.

```