```html How to Build a Chat App in SwiftUI (2026)

How to Build a Chat App in SwiftUI

A production-grade Chat App delivers real-time messaging between users over a WebSocket connection, with offline persistence via SwiftData and smooth, keyboard-aware UI — all in pure SwiftUI. It's ideal for indie developers building community tools, team communication utilities, or consumer social apps.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Advanced Estimated time: 2–4 weeks Updated: May 12, 2026

Prerequisites

Architecture overview

The app is split into three layers: a service layer (WebSocketService, AuthService) that handles raw network I/O and publishes events via Combine PassthroughSubjects; a persistence layer (SwiftData @Model types + a thin MessageRepository) that writes incoming frames to disk; and a view layer of @Observable view models that merge live WebSocket events with SwiftData query results. NavigationSplitView ties the conversation list to the chat detail, and a custom ComposeBarView tracks keyboard safe-area insets. Push notifications (APNs) wake the app when the socket is closed.

ChatApp/
├── App/
│   └── ChatApp.swift          # @main, ModelContainer setup
├── Models/
│   ├── Conversation.swift     # @Model — id, title, lastMessage
│   ├── Message.swift          # @Model — id, body, senderId, timestamp, status
│   └── User.swift             # @Model — id, displayName, avatarURL
├── Services/
│   ├── WebSocketService.swift # URLSessionWebSocketTask + Combine
│   ├── AuthService.swift      # Token auth, Keychain storage
│   └── MessageRepository.swift# SwiftData read/write helpers
├── ViewModels/
│   ├── ConversationListViewModel.swift  # @Observable
│   └── ChatViewModel.swift             # @Observable
├── Views/
│   ├── ConversationListView.swift
│   ├── ChatView.swift
│   ├── MessageBubbleView.swift
│   └── ComposeBarView.swift
└── PrivacyInfo.xcprivacy

Step-by-step

1. Project setup

Create a new SwiftUI + SwiftData project in Xcode 16. Under Signing & Capabilities add Push Notifications and ensure Outgoing Connections (Client) is checked in the App Sandbox entitlement. Add a Config.xcconfig for your WebSocket URL so it's not hard-coded.

// ChatApp.swift
import SwiftUI
import SwiftData

@main
struct ChatApp: App {
    let container: ModelContainer = {
        let schema = Schema([Conversation.self, Message.self, User.self])
        let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        return try! ModelContainer(for: schema, configurations: [config])
    }()

    var body: some Scene {
        WindowGroup {
            RootView()
                .modelContainer(container)
        }
    }
}

2. Data model with SwiftData

Define your three @Model classes. SwiftData automatically handles relationships — a Conversation has many Messages, each Message belongs to a sender User. The status enum tracks send/delivery/read state for per-bubble receipts.

// Models/Message.swift
import SwiftData
import Foundation

enum MessageStatus: String, Codable {
    case sending, sent, delivered, read, failed
}

@Model
final class Message {
    var id: String
    var body: String
    var senderId: String
    var timestamp: Date
    var status: MessageStatus
    var conversationId: String

    init(id: String, body: String, senderId: String,
         conversationId: String, status: MessageStatus = .sending) {
        self.id = id
        self.body = body
        self.senderId = senderId
        self.timestamp = .now
        self.status = status
        self.conversationId = conversationId
    }
}

// Models/Conversation.swift
@Model
final class Conversation {
    var id: String
    var title: String
    var lastMessagePreview: String
    var updatedAt: Date
    @Relationship(deleteRule: .cascade) var messages: [Message]

    init(id: String, title: String) {
        self.id = id
        self.title = title
        self.lastMessagePreview = ""
        self.updatedAt = .now
        self.messages = []
    }
}

3. WebSocket service with Combine

Wrap URLSessionWebSocketTask in a Combine-friendly service. A recursive receive() loop feeds decoded payloads into a PassthroughSubject. Exponential-backoff reconnection is handled in the connect() method so callers don't need to worry about drop events.

// Services/WebSocketService.swift
import Foundation
import Combine

struct IncomingFrame: Decodable {
    let type: String        // "message" | "ack" | "typing"
    let conversationId: String
    let payload: Data
}

@Observable
final class WebSocketService {
    private(set) var isConnected = false
    let frames = PassthroughSubject<IncomingFrame, Never>()

    private var task: URLSessionWebSocketTask?
    private let url: URL
    private var retryDelay: TimeInterval = 1

    init(url: URL) { self.url = url }

    func connect() {
        let session = URLSession(configuration: .default)
        task = session.webSocketTask(with: url)
        task?.resume()
        isConnected = true
        retryDelay = 1
        receive()
    }

    func disconnect() {
        task?.cancel(with: .goingAway, reason: nil)
        isConnected = false
    }

    func send(_ data: Data) async throws {
        try await task?.send(.data(data))
    }

    private func receive() {
        task?.receive { [weak self] result in
            guard let self else { return }
            switch result {
            case .success(let message):
                if case .data(let data) = message,
                   let frame = try? JSONDecoder().decode(IncomingFrame.self, from: data) {
                    self.frames.send(frame)
                }
                self.receive()             // loop
            case .failure:
                self.isConnected = false
                self.scheduleReconnect()
            }
        }
    }

    private func scheduleReconnect() {
        let delay = retryDelay
        retryDelay = min(retryDelay * 2, 60)
        Task {
            try? await Task.sleep(for: .seconds(delay))
            self.connect()
        }
    }
}

4. Conversation list UI

Use NavigationSplitView for iPad/Mac compatibility and a @Query macro to fetch conversations sorted by updatedAt descending. The sidebar automatically reflects new messages arriving via SwiftData without manual refreshes.

// Views/ConversationListView.swift
import SwiftUI
import SwiftData

struct ConversationListView: View {
    @Query(sort: \Conversation.updatedAt, order: .reverse)
    private var conversations: [Conversation]

    @State private var selectedConversation: Conversation?

    var body: some View {
        NavigationSplitView {
            List(conversations, selection: $selectedConversation) { convo in
                ConversationRowView(conversation: convo)
                    .tag(convo)
            }
            .navigationTitle("Messages")
        } detail: {
            if let convo = selectedConversation {
                ChatView(conversation: convo)
            } else {
                ContentUnavailableView("Select a conversation",
                                       systemImage: "bubble.left.and.bubble.right")
            }
        }
    }
}

struct ConversationRowView: View {
    let conversation: Conversation
    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(conversation.title).font(.headline)
            Text(conversation.lastMessagePreview)
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .lineLimit(1)
        }
        .padding(.vertical, 4)
    }
}

#Preview {
    ConversationListView()
        .modelContainer(for: [Conversation.self, Message.self], inMemory: true)
}

5. Chat view with message bubbles

A ScrollViewReader auto-scrolls to the latest message. The compose bar uses .safeAreaInset(edge: .bottom) so it tracks the keyboard without requiring ignoresSafeArea hacks. Message bubbles adapt color based on whether the senderId matches the local user.

// Views/ChatView.swift
import SwiftUI
import SwiftData

struct ChatView: View {
    let conversation: Conversation
    @Environment(ChatViewModel.self) private var vm
    @Query private var messages: [Message]

    init(conversation: Conversation) {
        self.conversation = conversation
        _messages = Query(
            filter: #Predicate { $0.conversationId == conversation.id },
            sort: \.timestamp
        )
    }

    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(spacing: 8) {
                    ForEach(messages) { msg in
                        MessageBubbleView(message: msg)
                            .id(msg.id)
                    }
                }
                .padding(.horizontal)
                .padding(.top, 8)
            }
            .onChange(of: messages.count) {
                if let last = messages.last {
                    withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
                }
            }
        }
        .navigationTitle(conversation.title)
        .navigationBarTitleDisplayMode(.inline)
        .safeAreaInset(edge: .bottom) {
            ComposeBarView(conversationId: conversation.id)
        }
    }
}

// Views/MessageBubbleView.swift
struct MessageBubbleView: View {
    let message: Message
    private let myId = AuthService.shared.userId

    var isMe: Bool { message.senderId == myId }

    var body: some View {
        HStack {
            if isMe { Spacer(minLength: 60) }
            VStack(alignment: isMe ? .trailing : .leading, spacing: 2) {
                Text(message.body)
                    .padding(.horizontal, 14)
                    .padding(.vertical, 10)
                    .background(isMe ? Color.blue : Color(.systemGray5))
                    .foregroundStyle(isMe ? .white : .primary)
                    .clipShape(RoundedRectangle(cornerRadius: 18))
                Text(message.timestamp, style: .time)
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }
            if !isMe { Spacer(minLength: 60) }
        }
    }
}

#Preview {
    let msg = Message(id: "1", body: "Hello!", senderId: "me", conversationId: "c1", status: .read)
    return MessageBubbleView(message: msg)
}

6. Real-time messaging — core feature

The ChatViewModel subscribes to the WebSocketService publisher on init, decodes incoming frames, and upserts Messages into the SwiftData context. Outgoing messages are optimistically inserted locally, then sent over the socket; the server echoes an ack which flips the status to .sent.

// ViewModels/ChatViewModel.swift
import SwiftUI
import SwiftData
import Combine

@Observable
final class ChatViewModel {
    var draftText = ""
    private let ws: WebSocketService
    private var modelContext: ModelContext
    private var cancellables = Set<AnyCancellable>()

    init(ws: WebSocketService, modelContext: ModelContext) {
        self.ws = ws
        self.modelContext = modelContext
        subscribeToFrames()
    }

    private func subscribeToFrames() {
        ws.frames
            .receive(on: DispatchQueue.main)
            .sink { [weak self] frame in
                guard frame.type == "message" else { return }
                self?.handleIncomingMessage(frame)
            }
            .store(in: &cancellables)
    }

    private func handleIncomingMessage(_ frame: IncomingFrame) {
        guard let incoming = try? JSONDecoder().decode(
            IncomingMessagePayload.self, from: frame.payload
        ) else { return }

        let message = Message(
            id: incoming.id,
            body: incoming.body,
            senderId: incoming.senderId,
            conversationId: frame.conversationId,
            status: .delivered
        )
        modelContext.insert(message)

        // Update conversation preview
        let predicate = #Predicate<Conversation> { $0.id == frame.conversationId }
        if let convo = try? modelContext.fetch(FetchDescriptor(predicate: predicate)).first {
            convo.lastMessagePreview = incoming.body
            convo.updatedAt = .now
        }

        try? modelContext.save()
    }

    func sendMessage(conversationId: String) {
        guard !draftText.trimmingCharacters(in: .whitespaces).isEmpty else { return }
        let body = draftText
        draftText = ""

        let tempId = UUID().uuidString
        let msg = Message(id: tempId, body: body,
                          senderId: AuthService.shared.userId,
                          conversationId: conversationId, status: .sending)
        modelContext.insert(msg)
        try? modelContext.save()

        Task {
            let payload = OutgoingMessagePayload(id: tempId, body: body, conversationId: conversationId)
            guard let data = try? JSONEncoder().encode(payload) else { return }
            try? await ws.send(data)
        }
    }
}

struct IncomingMessagePayload: Decodable {
    let id: String; let body: String; let senderId: String
}
struct OutgoingMessagePayload: Encodable {
    let id: String; let body: String; let conversationId: String
}

7. Authentication with Keychain

AuthService exchanges credentials for a JWT, stores the token in the Keychain via Security.framework, and injects the Authorization header into WebSocket upgrade requests using a custom URLSessionConfiguration. On cold launch it reads the cached token and reconnects automatically.

// Services/AuthService.swift
import Foundation
import Security

final class AuthService {
    static let shared = AuthService()
    private(set) var userId: String = ""

    private let tokenKey = "com.yourapp.authToken"

    var token: String? {
        get { readKeychain(key: tokenKey) }
        set {
            if let v = newValue { writeKeychain(key: tokenKey, value: v) }
            else { deleteKeychain(key: tokenKey) }
        }
    }

    func authenticatedWebSocketURL(base: URL) -> URL {
        guard let tok = token else { return base }
        var components = URLComponents(url: base, resolvingAgainstBaseURL: false)!
        components.queryItems = [URLQueryItem(name: "token", value: tok)]
        return components.url ?? base
    }

    // MARK: - Keychain helpers
    private func writeKeychain(key: String, value: String) {
        let data = Data(value.utf8)
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: key,
            kSecValueData: data,
            kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
        ]
        SecItemDelete(query as CFDictionary)
        SecItemAdd(query as CFDictionary, nil)
    }

    private func readKeychain(key: String) -> String? {
        let query: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrAccount: key,
            kSecReturnData: true,
            kSecMatchLimit: kSecMatchLimitOne
        ]
        var result: AnyObject?
        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
              let data = result as? Data else { return nil }
        return String(data: data, encoding: .utf8)
    }

    private func deleteKeychain(key: String) {
        let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key]
        SecItemDelete(query as CFDictionary)
    }
}

8. Unit testing the WebSocket service

Protocol-abstract the task so you can inject a MockWebSocketTask in tests. Assert that frames emits correctly decoded payloads and that the reconnect delay doubles on each failure — these two invariants catch most production socket bugs early.

// Tests/WebSocketServiceTests.swift
import XCTest
import Combine
@testable import ChatApp

final class WebSocketServiceTests: XCTestCase {
    private var cancellables = Set<AnyCancellable>()

    func testFrameDecodingPublishesOnSubject() throws {
        let url = URL(string: "wss://example.com/chat")!
        let service = WebSocketService(url: url)
        // Inject a mock connection (protocol abstraction not shown for brevity)

        let payload = IncomingMessagePayload(id: "42", body: "Hi", senderId: "u1")
        let inner = try JSONEncoder().encode(payload)
        let frame = IncomingFrame(type: "message", conversationId: "c1", payload: inner)
        let frameData = try JSONEncoder().encode(frame)

        let expectation = expectation(description: "frame received")
        service.frames
            .sink { received in
                XCTAssertEqual(received.conversationId, "c1")
                expectation.fulfill()
            }
            .store(in: &cancellables)

        // Simulate the task delivering data
        service.simulateReceive(data: frameData)

        wait(for: [expectation], timeout: 1)
    }
}

9. Privacy Manifest — required for App Store

A Chat App collects User IDs (to identify who sent a message) and uses the NSPrivacyAccessedAPICategoryUserDefaults required-reason API. Add PrivacyInfo.xcprivacy to your app target — submissions without it are rejected at upload time.

<!-- PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key><false/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypeUserID</string>
      <key>NSPrivacyCollectedDataTypeLinked</key><true/>
      <key>NSPrivacyCollectedDataTypeTracking</key><false/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array>
        <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
      </array>
    </dict>
  </array>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>CA92.1</string></array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Freemium

Implement freemium with StoreKit 2's Product.products(for:) to fetch one or more subscription SKUs (e.g. "Pro — unlimited conversations" vs. a free tier capped at 5 conversations or 100 messages/month). Gate features locally by reading Transaction.currentEntitlement(for:) on launch and storing the entitlement state in an @Observable EntitlementManager. Display an upgrade sheet using .presentStoreProductSheet(id:) or a custom SwiftUI paywall. Because your server enforces limits too, pass the verified StoreKit receipt token in the WebSocket handshake header so the backend can unlock higher rate limits server-side without trusting the client alone.

Shipping this faster with Soarias

Soarias automates the parts of an advanced project like this that eat the most time outside of writing code. It generates the SwiftData model scaffold from a JSON schema, pre-fills your PrivacyInfo.xcprivacy based on the entitlements it detects in your project, wires fastlane match for code signing, captures App Store screenshots on a real device matrix, and submits the binary plus all required metadata to App Store Connect — all from a single command prompt in Claude Code.

For an advanced Chat App — realistically a 2–4 week build — Soarias typically compresses the DevOps and submission work (normally 3–5 days of certificate wrangling, metadata entry, and review back-and-forth) down to a few hours. That's the ceiling it targets: every hour spent on App Store plumbing instead of your WebSocket logic is an hour Soarias can give back.

Related guides

FAQ

Does this work on iOS 16?

The guide targets iOS 17+ because it relies on the @Observable macro, SwiftData, and the #Preview macro — all unavailable on iOS 16. You could back-port using ObservableObject and Core Data, but you'd lose significant ergonomics and the code diverges substantially.

Do I need a paid Apple Developer account to test?

You can run on a personal-team device without a paid account, but you'll hit a 3-app limit, won't get push notification entitlements (critical for a chat app), and can't distribute via TestFlight. For anything beyond hobby testing the $99/year Developer Program is effectively required.

How do I add this to the App Store?

Archive your app in Xcode (Product → Archive), then upload via Xcode Organizer or xcrun altool. In App Store Connect create a new app record, fill in the privacy nutrition labels (especially User ID collection), set your age rating, add screenshots for all required device sizes, and submit for review. First reviews typically take 24–72 hours.

How do I handle WebSocket reconnections reliably under iOS network transitions?

Use NWPathMonitor from the Network framework to observe path changes (Wi-Fi ↔ cellular ↔ offline). When a path becomes .satisfied after a gap, cancel the current task and call connect() fresh rather than waiting for the recursive receive loop to time out. Combine the monitor publisher with your exponential-backoff in WebSocketService so reconnects happen within a second of connectivity returning, not after a multi-second TCP timeout.

Last reviewed: 2026-05-12 by the Soarias team.

```