Learn how to quickly integrate rich Generative AI experiences directly into Stream Chat. Learn More

Building An iOS Chat App With SwiftUI

Ready to get started? In this tutorial we cover everything you need to know in order to build an iPhone chat app with the Stream iOS SDK.

At the end of the tutorial, you will have created a fully functional messaging application that you can easily extend until it meets your particular use case.

Prefer to skip the setup? Add the Stream skill and let your AI agent build your iOS chat app.

This tutorial teaches you how to build a fully featured iOS messaging app with SwiftUI and the Stream Chat SDK — channel list, message composer, reactions, threads, typing indicators, and offline support. You can use it as the foundation to build any type of in-app chat or messaging.

On the right is a preview of the finished app. A complete sample app is also available in our repo.

This page works for both humans and AI coding agents: every step is a file operation or shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.

Your app runs on Stream's edge network for optimal performance, across multiple pricing tiers including a free maker plan.

Preview of the finished SwiftUI chat app

Not using SwiftUI? Follow the UIKit iOS chat tutorial instead.

Choose your path

Install the Stream CLI once, then add the skills. This gives Claude Code, Cursor, or Codex the iOS integration patterns and current SDK APIs, so it builds against real docs instead of stale training data.

bash
1
2
3
4
5
6
7
# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder). The iOS pack installs # on demand the first time it's needed, or add it explicitly: getstream skills getstream skills stream-swift

Then ask your agent:

plaintext
1
2
3
/stream-swift Add Stream Chat to my SwiftUI app with a channel list and message view. Provision credentials with the CLI (create/select my org and app, mint a token), or fall back to the tutorial demo credentials if I'm not logged in.

The agent pauses and hands back to you at two points: browser sign-in during getstream init, and running the app.

Path B — Build it manually

Follow the steps below. Every code block is a complete file — no fragments to assemble.

For AI assistants reading this page: append .md to any Stream docs URL for a clean Markdown version. A condensed index for the iOS SwiftUI SDK is at https://getstream.io/chat/sdk/ios/llms.txt; complete single-file docs are at llms-full.txt. Prefer these over parsing HTML.

Important Building Blocks

Three pieces, one mental model:

  • ChatClient — Low-level API client. Handles auth, websockets, and offline storage.
  • StreamChat — SwiftUI context object. Owns appearance (colors, fonts, images) and utilities. Created once, at app launch.
  • UI componentsChatChannelListView and its building blocks. Use them as-is, theme them, or swap individual views via a ViewFactory.

You'll get a working app with the default UI first, then theme it, then customize one piece of the message list. Deeper customizations are linked at the end.

Prerequisites

  • Xcode 26 (or latest)
  • Stream Chat SwiftUI SDK version 5.0.0 (or latest) - you add this in Step 2

Step 0 - Provision credentials with the Stream CLI

You need an API key and a user token, both belonging to your own Stream app. The getstream CLI provisions them in one flow. Run these from your project directory.

1. Install the CLI (skip if you did this in Path A):

bash
1
curl -fsSL https://getstream.io/cli.sh | bash

2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The sign-in flow creates your organization. Already have an org or an app? It lets you pick them.

bash
1
getstream init

🧑 Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish logging in before continuing. It's required first — env, token, and api commands fail with "stream project is not initialized" until it runs.

3. Write your API key into the iOS project. This creates a Secrets.xcconfig entry so the key is read at runtime instead of hardcoded in source. The API secret is never printed or written into the app.

bash
1
getstream env --target ios

4. Mint a user token for a user in your app (never-expiring by default; add a TTL for production-like testing):

bash
1
2
getstream token tutorial_user getstream token tutorial_user --ttl 1d

5. (Optional) Seed a channel so your first launch isn't an empty list. Create the users first, then the channel:

bash
1
2
3
4
getstream api UpdateUsers --request '{"users":{"tutorial_user":{"id":"tutorial_user","name":"Tutorial User"},"alice":{"id":"alice","name":"Alice"}}}' getstream api GetOrCreateChannel --type messaging --id general \ --request '{"data":{"created_by_id":"tutorial_user","members":[{"user_id":"tutorial_user"},{"user_id":"alice"}]}}'

Checkpoint: you have an API key (in Secrets.xcconfig) and a token printed by the CLI, both belonging to your own app. Keep the token for Step 3.

Fallback: tutorial demo credentials

Want to skip account setup entirely? These work against Stream's shared, pre-seeded tutorial environment:

  • API key8br4watad788
  • User IDluke_skywalker
  • TokeneyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIifQ.kFSLHRB5X62t0Zlc7nwczWUfsQMwfkpylC6jCUZ6Mc0

Swap in your own credentials from the CLI flow above before building anything real.

Step 1 - Create the project

The iOS app itself is created in Xcode — this can't be scaffolded from the CLI.

In Xcode: File -> New -> Project -> iOS -> App. Name it ChatDemo, Interface: SwiftUI, Language: Swift, minimum deployment iOS 17.0.

Screenshot showing the creation of a project

Agents: detect the project shape before doing anything (find . -maxdepth 3 -name "*.xcodeproj" -o -name "Package.swift"). If the directory is empty with no Xcode project, stop and ask the human to create the app in Xcode first — don't scaffold it yourself.

Checkpoint:

bash
1
xcodebuild -scheme ChatDemo -destination 'generic/platform=iOS Simulator' build

Step 2 - Add the SDK dependency

Pin to the 5.x major explicitly. Stream still ships a 4.x line, and an unpinned "Add Package" can silently resolve to v4, which lacks the v5 APIs this tutorial uses and won't compile.

Use the following steps to add the SDK via Swift Package Manager:

  • Select "Add Packages..." in File menu
  • Paste the URL https://github.com/getstream/stream-chat-swiftui
  • In the option "Dependency Rule" choose "Up to next major version", and in the text input next to it, enter "5.0.0".

Screenshot showing the SPM setup

  • Choose "Add Package" and wait for the dialog to complete
  • Only select "StreamChatSwiftUI" and select "Add Package" again

Screenshot showing the selection of the SwiftUI package

Agents: editing project.pbxproj by hand to add an SPM dependency is fragile. For a plain .xcodeproj with no Package.swift, ask the human to add the package in Xcode (30 seconds) rather than patching the pbxproj.

Checkpoint: xcodebuild -resolvePackageDependencies succeeds, resolves a 5.x version, and import StreamChatSwiftUI compiles.

Step 3 - Get a working app

Replace the contents of ChatDemoApp.swift with this complete file. This gives you the full default chat experience - channel list, message view, reactions, threads, typing indicators, offline support.

swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import SwiftUI import StreamChat import StreamChatSwiftUI /// Credentials from Step 0. /// - API key: `getstream env --target ios` writes it to Secrets.xcconfig, /// surfaced through Info.plist and read here via Bundle. Falls back to the demo key. /// - Token: `getstream token <user_id>` — paste it below, or use the demo token. enum StreamConfig { static let apiKey = (Bundle.main.object(forInfoDictionaryKey: "STREAM_API_KEY") as? String) .flatMap { $0.isEmpty ? nil : $0 } ?? "8br4watad788" static let userId = "luke_skywalker" static let userName = "Luke Skywalker" static let userToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibHVrZV9za3l3YWxrZXIifQ.kFSLHRB5X62t0Zlc7nwczWUfsQMwfkpylC6jCUZ6Mc0" } @main struct ChatDemoApp: App { private let chatClient: ChatClient @State private var streamChat: StreamChat init() { var config = ChatClientConfig(apiKey: .init(StreamConfig.apiKey)) // Offline support: channels and messages are cached locally, // so the app works without a connection. config.isLocalStorageEnabled = true let client = ChatClient(config: config) self.chatClient = client // StreamChat is the SwiftUI context object: appearance, fonts, utils. self._streamChat = State(initialValue: StreamChat(chatClient: client)) } var body: some Scene { WindowGroup { ChatChannelListView() .task { await connectUser() } } } private func connectUser() async { // Development token from `getstream token`. In production, fetch the // token from your backend after login — never hardcode secrets. guard let token = try? Token(rawValue: StreamConfig.userToken) else { log.error("Invalid token") return } do { try await chatClient.connectUser( userInfo: .init( id: StreamConfig.userId, name: StreamConfig.userName, imageURL: URL(string: "https://getstream.io/random_png/?name=\(StreamConfig.userName)") ), token: token ) } catch { log.error("Connecting the user failed: \(error)") } } }

The user connection runs in .task, tied to the view lifecycle, and works correctly under Swift 6 strict concurrency.

Checkpoint: build and run. You should see the channel list with no log.error output. Tap a channel → the message view opens. Send a message, long-press it for reactions, open a thread. Empty list but no error? Your user has no channels yet — seed one with the getstream api GetOrCreateChannel command from Step 0.

Screenshot showing the channel list

Optional — enable photo & camera attachments. The composer supports attachments once iOS has usage descriptions. Add these keys to the target's Info.plist:

xml
1
2
3
4
<key>NSCameraUsageDescription</key> <string>Used to capture photos for chat messages.</string> <key>NSPhotoLibraryUsageDescription</key> <string>Used to attach images to chat messages.</string>

Step 4 - Theme the app

Appearance is configured once, when you create the StreamChat object: colors, fonts, and images flow through an Appearance value. Update the init() in ChatDemoApp.swift:

swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
init() { var config = ChatClientConfig(apiKey: .init(StreamConfig.apiKey)) config.isLocalStorageEnabled = true let client = ChatClient(config: config) self.chatClient = client // Colors: message bubble background and text for outgoing messages. let colors = Appearance.ColorPalette() let streamBlue = UIColor(red: 0, green: 108.0 / 255.0, blue: 255.0 / 255.0, alpha: 1) colors.chatBackgroundOutgoing = streamBlue colors.chatTextOutgoing = .white // Fonts: use a larger font for message bodies. let fonts = Appearance.FontsSwiftUI() fonts.body = Font.title3 // Images: replace the send button icon. let images = Appearance.Images() if let sendIcon = UIImage(systemName: "arrowshape.turn.up.right.fill") { images.composerSend = sendIcon } let appearance = Appearance() appearance.colorPalette = colors appearance.images = images appearance.fontsSwiftUI = fonts self._streamChat = State(initialValue: StreamChat( chatClient: client, appearance: appearance )) }

Checkpoint: outgoing messages render with the blue background and white text, message bodies use the larger font, and the send button shows the new icon.

Full appearance reference: changing colors, fonts, and images.

Screenshot showing custom appearance

Step 5 - Customize one piece of the message list

Theming changes tokens (colors, fonts, icons). To change an actual view, you provide a ViewFactory and override only the slot you want — every other view keeps its default. Here we swap the message avatar for a rounded-square one.

Create CustomFactory.swift:

swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import SwiftUI import StreamChat import StreamChatSwiftUI // A rounded-square avatar to replace the default circular one. struct RoundedAvatar: View { let imageURL: URL? var body: some View { AsyncImage(url: imageURL) { image in image.resizable().scaledToFill() } placeholder: { Image(systemName: "person.circle.fill") .resizable() .foregroundStyle(.secondary) } .frame(width: 40, height: 40) .clipShape(RoundedRectangle(cornerRadius: 8)) } } // Conform to ViewFactory and override only makeUserAvatarView — every other // view keeps its default. A ViewFactory must also provide a `styles` property; // RegularStyles() gives you the SDK defaults. class CustomFactory: ViewFactory { @Injected(\.chatClient) public var chatClient public var styles = RegularStyles() private init() {} public static let shared = CustomFactory() func makeUserAvatarView(options: UserAvatarViewOptions) -> some View { RoundedAvatar(imageURL: options.user.imageURL) } }

Then inject the factory where you create the channel list — change one line in body:

swift
1
2
ChatChannelListView(viewFactory: CustomFactory.shared) .task { await connectUser() }

That's the whole pattern: conform to ViewFactory, give it a styles property, implement the one make… method for the slot you want, and pass the factory in. The same approach swaps the message text/bubble (makeMessageTextView), reactions, the composer, and more.

AsyncImage is built into SwiftUI, so there's nothing extra to import. Stream also exposes NukeUI's LazyImage if you want disk caching for avatars.

Checkpoint: open a channel — message avatars are now rounded squares instead of circles.

In some apps, the chat is invoked from a different place than the channel list. In that case, you can display the channel view programmatically. The channel view provides many customization options; have a look at the chat channel components section of our docs.

Screenshot showing the channel

Customize further — cookbooks

For larger changes, Stream has full cookbooks. Each shows a real app clone (WhatsApp, Messenger, Slack-style) built by swapping ViewFactory slots.

Channel list

Chat / message view

Verify the whole build

bash
1
2
3
xcodebuild -scheme ChatDemo \ -destination 'platform=iOS Simulator,name=iPhone 17' \ build

Then launch and confirm the full loop: channel list loads → open a channel → send a message → long-press for reactions → open a thread. (If you enabled attachments, tap the composer's attachment button too.)

Troubleshooting

  • stream project is not initialized — CLI onboarding not run. Run getstream init in the project directory first.
  • Build resolves the wrong SDK / v5 APIs missing - package resolved to the 4.x line. Pin from: "5.0.0" (Up to Next Major), then reset package caches.
  • Blank screen after launchconnectUser failed. Check the console for log.error; confirm the API key and token belong to the same app.
  • token is invalid / auth error — token minted for a different app or expired. Re-mint with getstream token <user_id> and confirm the API key matches.
  • Channel list is empty, no errors — the user has no channels. Seed one with getstream api GetOrCreateChannel (Step 0).
  • Missing package product 'StreamChatSwiftUI' — package resolution stale. File → Packages → Reset Package Caches, or run xcodebuild -resolvePackageDependencies.
  • Custom view slot not applied — factory not injected. Pass your factory to ChatChannelListView(viewFactory:), not just defined.
  • Crash on attachment picker — missing Info.plist keys. Add NSCameraUsageDescription and NSPhotoLibraryUsageDescription (Step 3 note).
  • Concurrency warnings under Swift 6 — UI mutations off the main actor. Keep view-model interaction in view bodies / @MainActor contexts, and connect the user in .task.

Next steps

Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.

Video / audio room integration

For a complete social experience, Stream provides a Video & Audio calling iOS SDK, that works seamlessly with our chat products. If you want to learn more on how to integrate video into your apps, please check our docs and our tutorials about video calling and livestreaming.

We also have a guide on how to integrate video with chat.

Final Thoughts

We have shown you how to build a fully featured in-app chat experience with SwiftUI, which includes reactions, threads, typing indicators, offline storage, URL previews, user presence and more. It's pretty crazy how APIs & SwiftUI components enable you to build chat in hours. On top of that you now know how easy it is to add your own theme to the app and even fully customize its key components.

The chat app we built uses Stream's edge network for optimal performance and scalability. Stream powers thousands of apps and over a billion end users. There are several price tiers available, including a free plan for development and a free maker plan.

Both the Chat SDK for SwiftUI and the API have plenty more features available to support more advanced use-cases such as push notifications, content moderation, rich messages and more. Additionally, we have shown how to use our low-level state from the chat client, in case you want to build your own custom messaging experiences.

Machine-readable resources

For AI agents and coding assistants working with this SDK:

  • CLI + skills: curl -fsSL https://getstream.io/cli.sh | bash, then getstream skills and getstream skills stream-swift for the iOS pack. (Alternative: npx skills add GetStream/agent-skills -s stream.)
  • Provisioning: getstream init (auth + create/select org & app) → getstream env --target ios (API key to Secrets.xcconfig) → getstream token <user_id> (mint a token)
  • Data & config from the CLI: getstream api <Endpoint> --request '{...}' for users, channels, and messages
  • iOS integration skill: invoke /stream-swift in your agent for SwiftUI/UIKit setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/chat/sdk/ios/llms.txt (condensed) · llms-full.txt (complete, single file)
  • Markdown endpoints: append .md to any docs URL for a clean, token-efficient version
  • Source of truth for APIs: the SDK repository — check the pinned version's source rather than assuming APIs from training data

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.