How to Add a Feedback Board to a SwiftUI App
Kotlin, Flutter, React Native and JavaScript each have a setup guide on this blog. This is the one for Swift. By the end of it, your iPhone, iPad, Mac or Vision Pro app has a screen where people read the requests others have filed, vote for the ones they want, comment on them and file their own, without leaving the app. The screens are SwiftUI views that come with the package. Your part is a package dependency, one configure call, and a decision about where the board sits in your navigation.
This guide covers the current Swift SDK, 1.3 (see What's New in Swift SDK 1.3). Older versions have their own tutorials, for Swift SDK 1.0 and Swift SDK 1.1, and there are notes for 1.2.
The short version
- Add the package with Swift Package Manager and pick the latest release. It needs iOS 26, macOS 26 or visionOS 26.
- Call
SwiftlyFeedback.configure(environment:key:)in yourAppinitialiser. - Show
FeedbackListViewin a sheet, a tab or a pushed screen. The submit form, the detail screen and voting come with it. - Pass a user id, an email and each user's MRR, so votes carry weight and status emails reach people.
- Theme it with
SwiftlyFeedback.appearanceand switch parts on and off withSwiftlyFeedback.configuration.
What you need
The package supports iOS 26, macOS 26 and visionOS 26 or later. There is no watchOS or tvOS target. Build with Xcode 26 or later. You also need a FeedbackKit project and its API key, which every plan includes, Free too. Create a project in the dashboard; owners and admins find the key on the project's page and in its settings. Keys start with sf_.
The examples use Trailmate, a fictional hiking app whose users keep asking for offline maps.
1. Install the package
In Xcode, choose File > Add Package Dependencies, paste the repository URL, and pick the latest release with the Up to Next Major Version rule:
https://github.com/Swiftly-Developed/SwiftlyFeedbackKit-Swift-SDK.git
Add the SwiftlyFeedbackKit library to your app target. The package has no third-party dependencies. It runs a small build plugin that generates its localised string symbols, so Xcode asks you once to trust it. On CI, pass -skipPackagePluginValidation to xcodebuild. If you manage dependencies in a Package.swift, add the same URL at the latest release and list the SwiftlyFeedbackKit product in your target's dependencies.
2. Configure the SDK
Configure once, in your App initialiser, before any FeedbackKit view appears:
import SwiftUI
import SwiftlyFeedbackKit
@main
struct TrailmateApp: App {
init() {
SwiftlyFeedback.configure(environment: .production, key: "sf_your_api_key")
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
The key travels as an X-API-Key header on every request. It is a client key for one project, made to ship inside an app, and like any client key it can be read by anyone who unpacks your binary. It covers end-user actions such as reading, filing, voting and commenting. The admin API uses separate Bearer tokens. If you would rather keep the key out of your repository, put it in an .xcconfig file that git ignores, expose it through Info.plist, and read it with Bundle.main.object(forInfoDictionaryKey:).
Use .production in every build, DEBUG included. The other environments, .development, .testflight and .local, point at different servers, and a key belongs to the server it was created on. For the same reason, prefer this explicit call to configureAuto(keys:), which sends DEBUG builds to the development server.
One detail matters later: configure returns straight away and finishes in the background. It works out a user id, then publishes the client as SwiftlyFeedback.current. The views read current when they are created. The testing section shows how to handle a board that is on screen at launch.
3. Show the board with FeedbackListView
The smallest useful integration is a button that opens the list in a sheet:
import SwiftUI
import SwiftlyFeedbackKit
struct ContentView: View {
@State private var showingIdeas = false
var body: some View {
Button("Suggest a feature", systemImage: "lightbulb") {
showingIdeas = true
}
.sheet(isPresented: $showingIdeas) {
FeedbackListView()
}
}
}
That one view gives you a lot:
- Each request is a card with its status, the full title and description, the category, the comment count and a vote button.
- The list loads when it appears and is sorted by votes. A Sort & Filter menu in the toolbar sorts by votes, newest, oldest or comment count, and filters by status and category. The chosen sort is remembered between launches.
- Search matches titles and descriptions, with suggested status and category tokens.
- Pull to refresh on iPhone and iPad. Command-R refreshes on the Mac.
- A plus button opens the submit form, and an empty project shows a prompt to file the opening request.
- On iPad at regular width the list sits beside the detail screen.
The view brings its own navigation, so there is no NavigationStack to add here. Section 10 covers the case where you already have one.
4. The submit form
The list's plus button presents SubmitFeedbackView in a sheet and reloads the list when it closes. You can also present the form by itself, for example from a "Report a problem" row in Settings:
struct ReportProblemRow: View {
@State private var showingForm = false
var body: some View {
Button("Report a problem", systemImage: "exclamationmark.bubble") {
showingForm = true
}
.sheet(isPresented: $showingForm) {
SubmitFeedbackView {
// Runs after a successful submit, and after Cancel.
}
}
}
}
The form has a title, a description, a category picker (Feature Request, Bug Report, Improvement or Other) and an optional email field. Once an email is typed, a mailing-list opt-in appears below it. On iPhone and iPad, Submit and Cancel sit at the bottom of the form. On the Mac they are in the toolbar, and Command-Return submits. While the form is incomplete, a caption names the missing field. The view dismisses itself, then calls your closure.
If filing requests should be a perk, for example for subscribers, turn it off and say why. The plus button then shows your message instead of the form, and everyone can still read and vote:
SwiftlyFeedback.configuration.allowFeedbackSubmission = false
SwiftlyFeedback.configuration.feedbackSubmissionDisabledMessage = "Trailmate Plus members can suggest new features."
Here is how the board looks in FeedbackKit, with sample data from Trailmate, the fictional hiking app:
One board on every screen
5. The detail screen
Tapping a card pushes FeedbackDetailView. It shows the title, description, status and category, the vote button with its count, and the comments, with a field for adding one. Replies your team writes in the dashboard are styled apart from users' comments. A Rejected request shows the reason you gave. The toolbar has a Share button, and on the Mac Command-R reloads the comments.
You can push it yourself. It takes a Feedback value, sets its own title and toolbar, and expects to be inside your NavigationStack. Here Trailmate's home screen surfaces the most-voted Approved request:
struct HomeView: View {
@State private var mostWanted: Feedback?
var body: some View {
List {
if let mostWanted {
Section("Most wanted") {
NavigationLink {
FeedbackDetailView(feedback: mostWanted)
} label: {
LabeledContent(mostWanted.title) {
Text(mostWanted.voteCount, format: .number)
}
}
}
}
// The rest of your home screen.
}
.task {
guard let client = SwiftlyFeedback.current else { return }
let approved = (try? await client.getFeedback(status: .approved)) ?? []
mostWanted = approved.max { $0.voteCount < $1.voteCount }
}
}
}
getFeedback reads every page unless you pass page. The client also has getFeedback(id:), submitFeedback(title:description:category:email:), getComments(for:) and addComment(to:content:) if you build screens of your own.
6. Voting
Voting needs no code of yours. A tap on the vote button, on a card or on the detail screen, casts a vote, and a second tap removes it. The rules behind it:
- A new request starts with its submitter's vote, so it shows a count of 1.
- Voting stops at Completed and Rejected. In the list those requests show their count as text instead of a button, and the server refuses new votes on them.
- Each user id votes once per request.
hasVotedon eachFeedbacktells the views which state to draw. allowUndoVote = falsemakes votes stick, andshowVoteCount = falsehides the counts.
Vote counts sort the board by default, and your team sees them beside each request in the dashboard. To vote from your own UI, the client has the same call. A repeat vote from the same user is a SwiftlyFeedbackError.conflict.
@MainActor
func upvote(_ feedback: Feedback) async throws -> Int? {
guard let client = SwiftlyFeedback.current else { return nil }
let result = try await client.vote(for: feedback.id)
return result.voteCount
}
Here is how voting looks in FeedbackKit, again with Trailmate's sample data:
Votes from inside the app
7. Who is voting: user id and MRR
Every request, vote and comment belongs to a user id. Without one from you, the SDK picks its own: an id already stored in the Keychain, then the iCloud record name when your app's Info.plist lists a CloudKit container identifier, then a generated id that it saves to the Keychain. The iCloud capability by itself is not enough: the SDK reads the container from a CKContainerIdentifiers array in Info.plist, and gives up on iCloud after three seconds. Keychain items survive a reinstall, so a returning user keeps their votes.
If your app has accounts, pass your own id after sign-in, so one person is one voter on every device:
@MainActor
func didSignIn(userID: String) {
SwiftlyFeedback.updateUser(customID: userID)
}
Set it as soon as you know who is signed in. The SDK does not move votes cast under the earlier anonymous id. An id you set before configure has finished is held and applied when the client comes up.
MRR is what turns a vote count into a priority. Pass what each user pays, and your team can sort requests by the total monthly revenue of the people behind them, on every plan. Give the amount and its period, and the SDK converts it to a monthly figure: yearly is divided by 12, quarterly by 3, and weekly is multiplied by 52 and divided by 12.
enum TrailmatePlan {
case monthly, yearly
}
func reportPlan(_ plan: TrailmatePlan?) {
switch plan {
case .monthly:
SwiftlyFeedback.updateUser(payment: .monthly(4.99))
case .yearly:
SwiftlyFeedback.updateUser(payment: .yearly(39.99))
case nil:
SwiftlyFeedback.clearUserPayment()
}
}
Unlike the custom id, a payment needs a live client. A call made while SwiftlyFeedback.current is still nil does nothing. Call it once your subscription state has loaded, from StoreKit or your own server, and again whenever it changes.
8. Status emails and the vote dialog
Telling people what happened to their request is what brings them back to the board. FeedbackKit sends status emails to two groups, under two separate rules:
- The submitter. If they filled in the email field on the submit form, they are emailed when their request moves to a status in the project's email list. That works on every plan. Choosing which statuses are in the list needs Pro ($15/month).
- Voters. A voter can ask to be told as well. Their opt-in is saved, and they are included in the send, when the project owner is on Team ($39/month). Below Team the opt-in is not stored.
A Rejected request's reason goes into its email, and a voter's email carries a one-click unsubscribe link. Closing the loop covers why this matters.
The vote dialog
Voters opt in through a dialog the SDK shows when someone votes and it has no email for them. It is titled "Vote for this feedback" and has an optional email field, a "Notify me of status changes" toggle and, once an address is typed, a "Subscribe to mailing list" opt-in. Skip votes without an email. Vote sends the address. The list and the detail screen present the same dialog, and you never embed it yourself.
When someone types an address, the SDK stores it in configuration.userEmail and does not ask again. Later votes use that address, with voteNotificationDefaultOptIn as the opt-in. Configuration lives in memory, so to keep the address across launches, save it yourself:
// In your App initialiser, after configure.
SwiftlyFeedback.configuration.userEmail = UserDefaults.standard.string(forKey: "feedbackEmail")
SwiftlyFeedback.configuration.onUserEmailChanged = { email in
UserDefaults.standard.set(email, forKey: "feedbackEmail")
}
If your app already knows the address, set it up front and the dialog does not appear. The submit form's email field starts filled in with it too. Votes then send that email with voteNotificationDefaultOptIn as the opt-in, so set that to true when your own settings record that the person wants these emails:
SwiftlyFeedback.configuration.userEmail = "maya@example.com"
SwiftlyFeedback.configuration.voteNotificationDefaultOptIn = true
To never ask, set showVoteEmailField = false, and votes go in without an email. The mailing-list opt-in feeds an email-list integration (Mailchimp, Kit, beehiiv or MailerLite, on Pro and up), and the server ignores it when none is set up. Hide it with showMailingListOptIn = false if you do not use one. Leave mailingListDefaultOptIn at false unless you have the person's consent, because with userEmail set it decides the subscription without asking.
9. Theming and configuration
Colours live on SwiftlyFeedback.appearance. Set them on the main actor, in your App initialiser for example:
SwiftlyFeedback.appearance.primaryColor = .adaptive(light: .green, dark: .mint)
SwiftlyFeedback.appearance.statusColors.testflight = .color(.teal)
SwiftlyFeedback.appearance.categoryColors.featureRequest = .color(.indigo)
primaryColor tints buttons and highlights, and .default follows your app's accent colour. Each status has a colour you can change. Category colours apply to the classic list and the detail screen. The rest of the list uses system colours, so light mode, dark mode and Increase Contrast look right without extra work.
Behaviour lives on SwiftlyFeedback.configuration, also on the main actor:
| Property | Default | What it does |
|---|---|---|
listStyle | .showcase | Cards. .classic draws a plain list with a vote column and badges |
allowUndoVote | true | Lets a second tap remove a vote |
showVoteCount | true | Shows vote counts |
showStatusBadge | true | Shows each request's status |
showCategoryBadge | true | Shows each request's category |
showCommentSection | true | Shows comments on the detail screen |
showEmailField | true | Shows the email field on the submit form |
userEmail | nil | Known address. Skips the vote dialog |
showVoteEmailField | true | Shows the vote dialog when there is no email |
voteNotificationDefaultOptIn | false | Starting state of the notify toggle |
showMailingListOptIn | true | Mailing-list opt-in on the form and in the dialog |
mailingListDefaultOptIn | false | Starting state of that opt-in |
allowFeedbackSubmission | true | Lets users file requests |
feedbackSubmissionDisabledMessage | nil | Your message when filing is off |
buttons.addButton.display | true | Shows the plus button |
buttons.statusFilter.display | true | Status filter in the Sort & Filter menu |
buttons.categoryFilter.display | true | Category filter in the same menu |
loggingEnabled | true | SDK log lines in the console |
The views come localised in eleven languages, English included, and support VoiceOver.
10. Where the board goes in your app
Whatever you choose, keep the submit form one tap from the list rather than behind a separate entry point. People who arrive to ask for offline maps often find the request already there, and vote for it instead of filing a duplicate.
A sheet
The example in section 3. It suits a "Suggest a feature" row in Settings or a button on an empty state. On iPhone the sheet closes with a swipe down.
A tab
Worth it when the board is a real part of the product, a place people come back to and follow. The list brings its own navigation, so it goes straight into the tab:
TabView {
Tab("Trails", systemImage: "map") {
TrailsView()
}
Tab("Ideas", systemImage: "lightbulb") {
FeedbackListView()
}
}
A screen pushed onto your NavigationStack
If the list lives inside a stack you already own, such as Settings, pass embedsNavigationStack: false so iPhone shows one navigation bar. The cards then push the detail screen onto your stack:
NavigationStack {
Form {
NavigationLink("Feature requests") {
FeedbackListView(embedsNavigationStack: false)
}
}
.navigationTitle("Settings")
}
The flag applies at compact width. On iPad at regular width the list is a split view with the detail beside it, and on the Mac it has its own stack, so there a tab, a sidebar destination or a window suits it better than a push.
A window on the Mac
var body: some Scene {
WindowGroup {
ContentView()
}
#if os(macOS)
Window("Feature Requests", id: "feedback") {
FeedbackListView()
}
#endif
}
Open it from a menu item or a button with @Environment(\.openWindow) private var openWindow and openWindow(id: "feedback").
11. Testing tips
Wait for the client when the board is on screen at launch. A view created while SwiftlyFeedback.current is still nil has no client and stays blank. Sheets, pushed screens and tabs the user opens later are fine. If the list is the opening screen, hold it back until the client exists:
struct IdeasScreen: View {
@State private var isReady = false
var body: some View {
if isReady {
FeedbackListView()
} else {
ProgressView()
.task {
while SwiftlyFeedback.current == nil {
try? await Task.sleep(for: .milliseconds(50))
}
isReady = true
}
}
}
}
A wrong key shows a screen, not an empty list. When the server rejects the key, the views show a "Configuration Error" screen in place of the board, and the list offers a Try Again button. Check the key and the environment together, since a key works on its own server.
Keep test requests off the real board. Everything you file while testing lands in the project your key belongs to. Pro includes two projects, so you can give DEBUG builds a key from a second one with #if DEBUG around the configure call. Otherwise, delete test requests in the dashboard afterwards.
Start over as a new user. Deleting the app does not reset the id, because the Keychain keeps it. Call UserIdentifier.clearUserId() from a debug menu and relaunch. If your Info.plist lists a CloudKit container, the SDK picks the same iCloud record name again, so test on a device signed in to another Apple Account.
On Free, other users do not see the eleventh request. The Free plan keeps 10 visible requests per project. Past that, a new request is saved and the submit succeeds. Its submitter still sees it, because the SDK sends their user id with every request, but everyone else's list leaves it out. So check from a second user id, not the one that filed it. It is not an error. See pricing.
Use previews with sample data. Feedback has a public initialiser, so the detail screen renders in a preview without a server:
#Preview {
NavigationStack {
FeedbackDetailView(feedback: Feedback(
id: UUID(),
title: "Offline maps",
description: "Download a region before a hike so the map works without signal.",
status: .approved,
category: .featureRequest,
userId: "preview",
userEmail: nil,
voteCount: 248,
hasVoted: false,
commentCount: 0,
createdAt: Date(),
updatedAt: Date()
))
}
}
Let the Mac app reach the network. With App Sandbox on, as in Xcode's macOS template, tick Outgoing Connections (Client) under Signing & Capabilities, or every request fails.
Read the logs. The SDK logs each request's method, path and status code under the subsystem com.swiftlyfeedback.sdk in Console. It never logs the API key, request or response bodies, emails or user ids. Set loggingEnabled = false to silence it.
Where to go next
Once requests come in, the work moves to your side: triage them in the dashboard or the admin apps for iPhone, iPad and Mac, merge duplicates, move the ones you build through Approved, In Progress and TestFlight to Completed, and, on Pro and up, send them to GitHub, Linear or one of the other trackers FeedbackKit connects to. The full reference for every view, model and method is in the Swift SDK docs. Building for more than Apple platforms? The same board is in the Kotlin, Flutter and React Native guides.
Every plan includes the Swift SDK. Start free, copy your project key, and the board can be in your next TestFlight build.