Files
supabase/apps/docs/content/guides/getting-started/tutorials/with-swift.mdx
T
Miranda Limonczenko 7ce4ee53ae chore(docs) Retire supa-mdx-lint (#50602)
Closes
[DOCS-1289](https://linear.app/supabase/issue/DOCS-1289/get-the-linter-to-fix-what-it-flags-or-retirereplace-the-linter)

Stacked on #50600, which points contributors at the authoring skills.
Merge that one first.

## Problem

Contributors experienced friction with the linter. They felt nickle and
dimed for tiny nits and felt detracted from the work itself. PRs would
become noisy with tiny one-word suggestions.

Additionally, our homegrown linter is not very intelligent, causing
frequent overrides.

## Solution

This removes the linter entirely in favor of directing contributors to
use SKILLS instead.

The removal entails...

- **CI.** Delete the three `docs_lint` workflows: the PR check, the
external-PR comment companion, and the nightly `--fix` bot. Drop the
stale `zizmor.yml` ignore entry for the deleted workflow.
- **Tooling.** Delete `supa-mdx-lint.config.toml` and the 14 rule files.
Drop the `lint:mdx` script and the `@supabase/supa-mdx-lint` dependency
from docs, learn, and ui-library, and regenerate the lockfile.
- **Content.** Remove the 181 directives. A separate commit carries
Prettier's reformatting of the tables and blank lines those comments had
suppressed, so the deletion commit stays readable. No prose changes.
- **Style guide.** The word list states each rule directly instead of
describing what the linter flagged. Every term survives, including the
phrase groups that mirrored `Rule004ExcludeWords`.
- **Skills.** `write-the-docs`, `edit-the-docs`, and `review-the-docs`
drop `pnpm lint:mdx` from their self-review commands and check the word
list directly. `ask-the-docs`'s CI reference drops both workflows.

## Manual testing

1. Run `git grep -i supa-mdx-lint -- . ':!pnpm-lock.yaml'`. No matches.
2. Run `pnpm install --frozen-lockfile --lockfile-only`. It passes, so
the lockfile matches the three trimmed manifests.
3. Run `git diff master...HEAD --name-only --diff-filter=ACMR | grep -E
'\.(md|mdx)$' | xargs npx prettier --config prettier.config.mjs
--check`. All changed markdown passes.
4. Open the [reformatted filter
table](https://docs-git-docs-retire-mdx-linter-supabase.vercel.app/docs/guides/observability/logs#filter-events)
on the preview and compare it with
[production](https://supabase.com/docs/guides/observability/logs#filter-events).
The table renders the same.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Documentation guidance now uses manual prose and terminology review
with the shared word list.
* Clarified storage configuration and common Realtime channel mistakes.
* Improved table formatting, text wrapping, and selected reference
links.
  * Updated documentation authoring and review guidance.

* **Chores**
* Retired automated MDX linting from workflows and local validation
commands.
* Removed lint-suppression markers throughout documentation without
changing instructions.
  * Added targeted documentation review guidance for pull requests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 10:00:41 -07:00

561 lines
13 KiB
Plaintext

---
title: 'Build a User Management App with Swift and SwiftUI'
description: 'Learn how to use Supabase in your SwiftUI App.'
---
<$Partial path="quickstart_intro.mdx" />
![Supabase User Management example](/docs/img/supabase-swift-demo.png)
<Admonition type="note">
If you get stuck while working through this guide, you can find the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/swift-user-management).
</Admonition>
<$Partial path="project_setup.mdx" variables={{ "framework": "swift", "tab": "mobiles" }} />
## Building the app
Build the SwiftUI app from scratch.
### Create a SwiftUI app in Xcode
Open Xcode and create a new SwiftUI project.
Add the [supabase-swift](https://github.com/supabase/supabase-swift) dependency.
Add the `https://github.com/supabase/supabase-swift` package to your app. For instructions, see the [Apple tutorial on adding package dependencies](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app).
Create a helper file to initialize the Supabase client.
You need the API URL and the key that you copied [earlier](#get-api-details).
These variables will be exposed on the application, and that's completely fine since you have
[Row Level Security](/docs/guides/auth#row-level-security) enabled on your database.
<$CodeTabs>
```swift name=Supabase.swift
import Foundation
import Supabase
let supabase = SupabaseClient(
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
supabaseKey: "YOUR_SUPABASE_PUBLISHABLE_KEY"
)
```
</$CodeTabs>
### Set up a sign-in view
Set up a SwiftUI view to manage sign-ins and sign-ups.
Users should be able to sign in using a magic link.
<$CodeTabs>
```swift name=AuthView.swift
import SwiftUI
import Supabase
struct AuthView: View {
@State var email = ""
@State var isLoading = false
@State var result: Result<Void, Error>?
var body: some View {
Form {
Section {
TextField("Email", text: $email)
.textContentType(.emailAddress)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section {
Button("Sign in") {
signInButtonTapped()
}
if isLoading {
ProgressView()
}
}
if let result {
Section {
switch result {
case .success:
Text("Check your inbox.")
case .failure(let error):
Text(error.localizedDescription).foregroundStyle(.red)
}
}
}
}
.onOpenURL(perform: { url in
Task {
do {
try await supabase.auth.session(from: url)
} catch {
self.result = .failure(error)
}
}
})
}
func signInButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
try await supabase.auth.signInWithOTP(
email: email,
redirectTo: URL(string: "io.supabase.user-management://login-callback")
)
result = .success(())
} catch {
result = .failure(error)
}
}
}
}
```
</$CodeTabs>
<Admonition type="note">
The example uses a custom `redirectTo` URL. For this to work, add a custom redirect URL to Supabase and a custom URL scheme to your SwiftUI application. Follow the guide on [implementing deep link handling](/docs/guides/auth/native-mobile-deep-linking?platform=swift).
</Admonition>
### Account view
After a user is signed in, you can allow them to edit their profile details and manage their account.
Create a new view for that called `ProfileView.swift`.
<$CodeTabs>
```swift name=ProfileView.swift
import SwiftUI
struct ProfileView: View {
@State var username = ""
@State var fullName = ""
@State var website = ""
@State var isLoading = false
var body: some View {
NavigationStack {
Form {
Section {
TextField("Username", text: $username)
.textContentType(.username)
.textInputAutocapitalization(.never)
TextField("Full name", text: $fullName)
.textContentType(.name)
TextField("Website", text: $website)
.textContentType(.URL)
.textInputAutocapitalization(.never)
}
Section {
Button("Update profile") {
updateProfileButtonTapped()
}
.bold()
if isLoading {
ProgressView()
}
}
}
.navigationTitle("Profile")
.toolbar(content: {
ToolbarItem(placement: .topBarLeading){
Button("Sign out", role: .destructive) {
Task {
try? await supabase.auth.signOut()
}
}
}
})
}
.task {
await getInitialProfile()
}
}
func getInitialProfile() async {
do {
let currentUser = try await supabase.auth.session.user
let profile: Profile =
try await supabase
.from("profiles")
.select()
.eq("id", value: currentUser.id)
.single()
.execute()
.value
self.username = profile.username ?? ""
self.fullName = profile.fullName ?? ""
self.website = profile.website ?? ""
} catch {
debugPrint(error)
}
}
func updateProfileButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
let currentUser = try await supabase.auth.session.user
try await supabase
.from("profiles")
.update(
UpdateProfileParams(
username: username,
fullName: fullName,
website: website
)
)
.eq("id", value: currentUser.id)
.execute()
} catch {
debugPrint(error)
}
}
}
}
```
</$CodeTabs>
### Models
In `ProfileView.swift`, you used 2 model types for deserializing the response and serializing the request to Supabase. Add those in a new `Models.swift` file.
<$CodeTabs>
```swift name=Models.swift
struct Profile: Decodable {
let username: String?
let fullName: String?
let website: String?
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
}
}
struct UpdateProfileParams: Encodable {
let username: String
let fullName: String
let website: String
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
}
}
```
</$CodeTabs>
## Profile photos
Next, add a way for users to upload a profile photo. Supabase configures every project with [Storage](/docs/guides/storage) for managing large files like photos and videos.
### Add `PhotosPicker`
Add support for the user to pick an image from the library and upload it.
Start by creating a new type to hold the picked avatar image:
<$CodeTabs>
```swift name=AvatarImage.swift
import SwiftUI
struct AvatarImage: Transferable, Equatable {
let image: Image
let data: Data
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(importedContentType: .image) { data in
guard let image = AvatarImage(data: data) else {
throw TransferError.importFailed
}
return image
}
}
}
extension AvatarImage {
init?(data: Data) {
guard let uiImage = UIImage(data: data) else {
return nil
}
let image = Image(uiImage: uiImage)
self.init(image: image, data: data)
}
}
enum TransferError: Error {
case importFailed
}
```
</$CodeTabs>
#### Add `PhotosPicker` to profile page
<$CodeTabs>
```swift name=ProfileView.swift
import PhotosUI
import Storage
import Supabase
import SwiftUI
struct ProfileView: View {
@State var username = ""
@State var fullName = ""
@State var website = ""
@State var isLoading = false
@State var imageSelection: PhotosPickerItem?
@State var avatarImage: AvatarImage?
var body: some View {
NavigationStack {
Form {
Section {
HStack {
Group {
if let avatarImage {
avatarImage.image.resizable()
} else {
Color.clear
}
}
.scaledToFit()
.frame(width: 80, height: 80)
Spacer()
PhotosPicker(selection: $imageSelection, matching: .images) {
Image(systemName: "pencil.circle.fill")
.symbolRenderingMode(.multicolor)
.font(.system(size: 30))
.foregroundColor(.accentColor)
}
}
}
Section {
TextField("Username", text: $username)
.textContentType(.username)
.textInputAutocapitalization(.never)
TextField("Full name", text: $fullName)
.textContentType(.name)
TextField("Website", text: $website)
.textContentType(.URL)
.textInputAutocapitalization(.never)
}
Section {
Button("Update profile") {
updateProfileButtonTapped()
}
.bold()
if isLoading {
ProgressView()
}
}
}
.navigationTitle("Profile")
.toolbar(content: {
ToolbarItem {
Button("Sign out", role: .destructive) {
Task {
try? await supabase.auth.signOut()
}
}
}
})
.onChange(of: imageSelection) { _, newValue in
guard let newValue else { return }
loadTransferable(from: newValue)
}
}
.task {
await getInitialProfile()
}
}
func getInitialProfile() async {
do {
let currentUser = try await supabase.auth.session.user
let profile: Profile =
try await supabase
.from("profiles")
.select()
.eq("id", value: currentUser.id)
.single()
.execute()
.value
username = profile.username ?? ""
fullName = profile.fullName ?? ""
website = profile.website ?? ""
if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
try await downloadImage(path: avatarURL)
}
} catch {
debugPrint(error)
}
}
func updateProfileButtonTapped() {
Task {
isLoading = true
defer { isLoading = false }
do {
let imageURL = try await uploadImage()
let currentUser = try await supabase.auth.session.user
let updatedProfile = Profile(
username: username,
fullName: fullName,
website: website,
avatarURL: imageURL
)
try await supabase
.from("profiles")
.update(updatedProfile)
.eq("id", value: currentUser.id)
.execute()
} catch {
debugPrint(error)
}
}
}
private func loadTransferable(from imageSelection: PhotosPickerItem) {
Task {
do {
avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
} catch {
debugPrint(error)
}
}
}
private func downloadImage(path: String) async throws {
let data = try await supabase.storage.from("avatars").download(path: path)
avatarImage = AvatarImage(data: data)
}
private func uploadImage() async throws -> String? {
guard let data = avatarImage?.data else { return nil }
let filePath = "\(UUID().uuidString).jpeg"
try await supabase.storage
.from("avatars")
.upload(
filePath,
data: data,
options: FileOptions(contentType: "image/jpeg")
)
return filePath
}
}
```
</$CodeTabs>
Finally, update your Models.
<$CodeTabs>
```swift name=Models.swift
struct Profile: Codable {
let username: String?
let fullName: String?
let website: String?
let avatarURL: String?
enum CodingKeys: String, CodingKey {
case username
case fullName = "full_name"
case website
case avatarURL = "avatar_url"
}
}
```
</$CodeTabs>
You no longer need the `UpdateProfileParams` struct, as you can now reuse the `Profile` struct for both request and response calls.
### Launch!
With all the views in place, add an entry point for the application.
Add a new `AppView.swift` file.
<$CodeTabs>
```swift name=AppView.swift
import SwiftUI
struct AppView: View {
@State var isAuthenticated = false
var body: some View {
Group {
if isAuthenticated {
ProfileView()
} else {
AuthView()
}
}
.task {
for await state in supabase.auth.authStateChanges {
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
isAuthenticated = state.session != nil
}
}
}
}
}
```
</$CodeTabs>
Update the entry point to the newly created `AppView`. Run in Xcode to launch your application in the simulator.
At this stage you have a fully functional application!