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.

Not using SwiftUI? Follow the UIKit iOS chat tutorial instead.
Choose your path
Path A — Let your AI agent build it (recommended)
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.
1234567# 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:
123/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
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the iOS SwiftUI SDK is athttps://getstream.io/chat/sdk/ios/llms.txt; complete single-file docs are atllms-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 components —
ChatChannelListViewand its building blocks. Use them as-is, theme them, or swap individual views via aViewFactory.
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):
1curl -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.
1getstream 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.
1getstream 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):
12getstream 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:
1234getstream 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 key —
8br4watad788 - User ID —
luke_skywalker - Token —
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.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.

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:
1xcodebuild -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".

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

Agents: editing
project.pbxprojby hand to add an SPM dependency is fragile. For a plain.xcodeprojwith noPackage.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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768import 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.

Optional — enable photo & camera attachments. The composer supports attachments once iOS has usage descriptions. Add these keys to the target's
Info.plist:
1234<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:
123456789101112131415161718192021222324252627282930313233init() { 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.

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:
1234567891011121314151617181920212223242526272829303132333435import 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:
12ChatChannelListView(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.
AsyncImageis built into SwiftUI, so there's nothing extra to import. Stream also exposes NukeUI'sLazyImageif 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.

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
- View customization with
ViewFactory— the pattern for swapping any slot, including channel list items and the no-channels view - Cookbook overview
Chat / message view
- Custom message list — build a bespoke message list on the low-level client
- Custom composer — an iMessage-style composer
- Custom channel header — a WhatsApp-style navigation bar
- Custom avatar and custom message/attachment views
Verify the whole build
123xcodebuild -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. Rungetstream initin 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 launch —
connectUserfailed. Check the console forlog.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 withgetstream 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 runxcodebuild -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
NSCameraUsageDescriptionandNSPhotoLibraryUsageDescription(Step 3 note). - Concurrency warnings under Swift 6 — UI mutations off the main actor. Keep view-model interaction in view bodies /
@MainActorcontexts, and connect the user in.task.
Next steps
- AI chat experiences — add an AI assistant to a channel with streaming responses and typing indicators: AI integration
- Video & audio calls — the Video iOS SDK integrates with chat: chat integration guide
- Push notifications - iOS push setup
- Sample app — the complete demo: DemoAppSwiftUI on GitHub
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, thengetstream skillsandgetstream skills stream-swiftfor 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-swiftin your agent for SwiftUI/UIKit setup patterns;/stream-docssearches 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
.mdto 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

