·11 min read

Cursor Rules for Swift and SwiftUI: iOS AI Guide

Stop AI tools from writing 2021-era Swift. Practical Cursor rules for Swift and SwiftUI covering Observation, Swift 6 concurrency, and a copyable rule set.

Cursor rules for Swift solve one specific, expensive problem: AI assistants generate Swift that was idiomatic three or four years ago. Left unguided, the model reaches for ObservableObject and @Published instead of the Observation framework, NavigationView instead of NavigationStack, completion handlers instead of async/await, and DispatchQueue.main.async in a codebase that compiles under Swift 6 strict concurrency. Every one of those choices is code you have to catch in review or migrate later.

The fix is a project-level rules file — .cursor/rules/*.mdc in Cursor, CLAUDE.md in Claude Code — that pins the AI to your actual stack: Observation-based state, Swift 6 concurrency, Swift Testing, and your project layout. This guide walks through the rules that matter most for iOS work, why each one exists, and ends with a copyable rule set you can drop into any tool. For the general mechanics of how rule files work, see the complete Cursor rules guide.

Why AI needs Cursor rules for Swift in 2026

Swift has changed more in the last few years than almost any mainstream language, and the training data hasn't caught up. The overwhelming mass of public Swift code — tutorials, Stack Overflow answers, open-source apps — was written in the ObservableObject era. That skews AI output in predictable ways:

  • Pre-Observation state management. The model defaults to ObservableObject, @Published, @StateObject, and @EnvironmentObject. The Observation framework (@Observable, iOS 17+) replaced all of these with less boilerplate and better performance, but it's underrepresented in training data.
  • Old concurrency. Completion handlers, DispatchQueue, semaphores, and unstructured Task {} blocks scattered through view lifecycle callbacks. Under Swift 6 strict concurrency, much of this either won't compile or will compile with @unchecked Sendable escape hatches that defeat the point.
  • Deprecated SwiftUI APIs. NavigationView instead of NavigationStack, .foregroundColor instead of .foregroundStyle, onAppear + Task instead of .task.
  • XCTest by default. New projects should use Swift Testing (@Test, #expect) for unit tests, but the model writes XCTAssertEqual unless told otherwise.

None of this is the AI being wrong about Swift — it's the AI being right about old Swift. Rules exist to move the model's defaults forward to match your deployment target and toolchain. This is the same dynamic covered in our mobile development rules guide, sharpened to the Swift-specific failure modes.

SwiftUI Cursor rules: state management and Observation

State management is where outdated AI-generated Swift costs the most, because a wrong pattern here spreads. One ObservableObject view model invites ten more. Scope a rule file to your Swift sources and make the Observation conventions explicit.

Create .cursor/rules/swiftui-state.mdc:

---
description: SwiftUI state management conventions (Observation framework)
globs: ["**/*.swift"]
alwaysApply: false
---

## State management

- This project targets iOS 17+. Use the Observation framework for all shared state.
- Model classes use the @Observable macro:
    @Observable final class CartModel { var items: [CartItem] = [] }
- Views OWN an @Observable object with @State:
    @State private var model = CartModel()
- Views that RECEIVE an @Observable object take it as a plain property:
    let model: CartModel
- Use @Bindable only when a child view needs two-way bindings into an
  @Observable object.
- Inject app-wide dependencies with @Environment and .environment(_:), typed
  by the @Observable class itself.
- NEVER use ObservableObject, @Published, @StateObject, @ObservedObject, or
  @EnvironmentObject in new code. If you touch a file that still uses them,
  do not migrate it unless asked.
- @State is for view-local value types and owned @Observable objects. Nothing else.
- Navigation uses NavigationStack with a typed path. Never NavigationView.

The "views that receive an object take it as a plain property" rule surprises people coming from the old world, so it's worth spelling out — with Observation, tracking is automatic and @ObservedObject has no replacement because none is needed. When the model follows these swiftui cursor rules, generated views look like this:

@Observable
final class CartModel {
    var items: [CartItem] = []
    var total: Decimal { items.reduce(0) { $0 + $1.price } }
}

struct CartView: View {
    @State private var model = CartModel()

    var body: some View {
        List(model.items) { CartRow(item: $0) }
            .navigationTitle("Cart")
    }
}

No conformances, no property wrappers on the model, no objectWillChange. If your generated code still contains @Published, your rules file isn't being picked up — check the glob.

Swift 6 concurrency rules that prevent data-race code

Swift 6 strict concurrency turns data races into compile errors, which means AI-generated code that would have silently worked (or silently raced) in Swift 5 now fails the build. Without rules, the model's instinct is to reach for the escape hatches: @unchecked Sendable, nonisolated(unsafe), or falling back to GCD. Rules should close those exits and steer toward structured concurrency:

## Concurrency (Swift 6, strict checking enabled)

- All code must compile under the Swift 6 language mode. No downgrading
  the language mode or disabling strict concurrency to make an error go away.
- UI-facing types are @MainActor. Mark view models @MainActor @Observable.
- Use actor types for shared mutable state that lives off the main actor
  (caches, database handles, network session state).
- async/await everywhere: never completion handlers, DispatchQueue,
  OperationQueue, or semaphores in new code.
- Prefer structured concurrency: async let for fixed fan-out, TaskGroup for
  dynamic fan-out. Unstructured Task {} needs a stated reason.
- In SwiftUI, use .task and .task(id:) for view-lifecycle async work. Never
  Task inside onAppear — it does not cancel when the view disappears.
- @unchecked Sendable and nonisolated(unsafe) require a comment explaining
  why the type is actually safe. Prefer restructuring over suppressing.
- Delegate callbacks and notification handlers hop to the right isolation
  explicitly; do not assume they arrive on the main actor.

The .task rule earns its place daily. AI tools love onAppear { Task { await load() } }, which leaks the task past the view's lifetime. The correct version is shorter and self-cancelling:

struct ProfileView: View {
    @State private var model = ProfileModel()

    var body: some View {
        List(model.entries) { EntryRow(entry: $0) }
            .task { await model.load() } // auto-cancelled on disappear
    }
}

One more thing worth stating in your rules if it applies: Swift 6.2's default main-actor isolation setting. If your app module builds with default MainActor isolation, tell the AI — it changes which annotations are redundant and which nonisolated marks are load-bearing. A one-line rule ("this module uses default MainActor isolation; only annotate types that must run off the main actor") prevents a lot of annotation noise.

Project structure and testing conventions

Structure rules keep the AI from inventing its own architecture every time it creates a file, and testing rules stop the XCTest reflex.

## Project structure

- Feature-first folders: Features/<FeatureName>/ contains that feature's
  views, models, and services together. No global Views/ or Models/ dumps.
- Shared infrastructure lives in local Swift packages (Networking, Persistence,
  DesignSystem) consumed via Swift Package Manager.
- Views stay small. Extract a subview before a body exceeds ~50 lines.
- Dependencies are injected through initializers or @Environment.
  No singletons accessed directly from views.
- No UIKit unless wrapping something SwiftUI cannot do; wrappers live in
  a UIKitBridge folder and conform to UIViewRepresentable.

## Testing

- Unit tests use Swift Testing: @Test functions, #expect and #require,
  @Suite for grouping. XCTest only for UI tests (XCUITest).
- Prefer parameterized tests with @Test(arguments:) over for-loops.
- Test names describe behavior: @Test("empty cart totals zero").
- Build and test from the command line so results are verifiable:
    xcodebuild test -scheme App \
      -destination 'platform=iOS Simulator,name=iPhone 16'

With these in place, generated tests come out in the modern form:

import Testing

@Suite("Cart totals")
struct CartTotalTests {
    @Test("empty cart totals zero")
    func emptyCart() {
        #expect(CartModel().total == 0)
    }

    @Test(arguments: [1, 3, 7])
    func countTracksAdds(count: Int) {
        let model = CartModel()
        for _ in 0..<count { model.add(.fixture()) }
        #expect(model.items.count == count)
    }
}

The xcodebuild line matters more than it looks. Unlike web projects where the agent can run npm test, iOS builds live in Xcode — and your AI tool cannot click the Run button. Giving the agent the exact CLI invocation turns "here's some code, hope it compiles" into a loop where it builds, reads the errors, and fixes its own concurrency violations.

Cross-tool setup for iOS AI coding

Most iOS teams don't live in a single editor: Xcode for building, profiling, and Interface previews; Cursor or Claude Code for the heavy generation work. Your rules need to follow the AI, not the editor. Here's the setup, step by step.

  1. Create the Cursor rule file. Put the sections above into .cursor/rules/swift.mdc with globs: ["**/*.swift"] so they attach whenever Swift files are in context. Split SwiftUI, concurrency, and testing into separate .mdc files if they grow — smaller scoped rules beat one giant always-on file.

  2. Mirror the conventions for Swift + Claude Code sessions. Claude Code reads CLAUDE.md at the repo root; the same rule text works nearly verbatim. See the CLAUDE.md guide for structuring it. Windsurf reads .windsurf/rules/. The rules are identical — only the container changes.

  3. Give every tool the build loop. Add the xcodebuild (or your Fastlane/Tuist equivalent) commands to the rules so agents can self-verify. This is the single biggest quality lever in iOS AI coding: an agent that can compile under Swift 6 mode catches its own Sendable mistakes before you see them.

  4. Publish once, install everywhere. Maintaining three copies of the same conventions drifts within weeks. On localskills.sh you publish the rule set as one versioned skill, and the CLI writes each tool-native format — .cursor/rules (.mdc) for Cursor, .claude/skills/ for Claude Code, .windsurf/rules/ for Windsurf:

npm install -g @localskills/cli
localskills install your-team/swift-rules --target cursor claude windsurf

When you bump the deployment target or adopt a new Swift version, update the skill once and everyone pulls the new version — with rollback if the change misfires.

The copyable Cursor rules for Swift

Here's the consolidated starter file. Adapt the stack section to your project and delete anything that doesn't apply:

# Project: [Your iOS App]

## Stack
- Swift 6, iOS 17+ deployment target
- SwiftUI only; UIKit only via UIViewRepresentable wrappers
- Observation framework for state, Swift Testing for unit tests
- Swift Package Manager for modules and dependencies

## SwiftUI state
- @Observable classes for shared state; views own them with @State
- Received objects are plain properties; @Bindable only for bindings
- Never ObservableObject, @Published, @StateObject, @ObservedObject,
  or @EnvironmentObject in new code
- NavigationStack with typed paths, never NavigationView

## Concurrency
- Compiles under Swift 6 strict concurrency; no language-mode downgrades
- @MainActor for UI-facing types; actor for off-main shared mutable state
- async/await only: no completion handlers, DispatchQueue, or semaphores
- Structured concurrency (async let, TaskGroup) over bare Task {}
- .task / .task(id:) for view async work, never Task in onAppear
- @unchecked Sendable requires a justifying comment

## Structure
- Feature-first folders under Features/; shared code in local SPM packages
- Extract subviews before a body exceeds ~50 lines
- Dependencies via initializers or @Environment; no singletons in views

## Testing
- Swift Testing (@Test, #expect, @Suite); XCTest only for UI tests
- Parameterized tests with @Test(arguments:)
- Verify with: xcodebuild test -scheme App
  -destination 'platform=iOS Simulator,name=iPhone 16'

Keep it under a page. Rules compete for context with your actual code, and a focused file that's always respected beats an exhaustive one that gets truncated. For the principles behind trimming and structuring rule files, see AI coding rules best practices.

FAQ

Do Cursor rules for Swift work when the project builds in Xcode?

Yes. Rules affect what the AI generates, not how the project builds — Xcode never reads them. The practical gap is verification: Cursor and Claude Code can't press Run in Xcode, so include CLI build commands (xcodebuild, Fastlane, or Tuist) in your rules so the agent can compile and test what it writes.

Should rules ban ObservableObject entirely?

Ban it in new code if you target iOS 17+, but tell the AI not to migrate existing files unprompted. Mixed codebases are normal mid-migration; a rule like "new features use @Observable; do not refactor existing ObservableObject types unless asked" prevents both outdated new code and surprise refactors.

What if my deployment target is below iOS 17?

Then Observation isn't available and the rule set flips: state your minimum OS explicitly and specify the ObservableObject + @StateObject patterns you want. The worst outcome is the AI guessing — an unstated deployment target produces code that mixes both eras.

How do I keep the same rules in Cursor, Claude Code, and Windsurf?

The content is identical; only the file format differs per tool. You can copy files by hand, or publish the rule set once as a skill and install it with localskills install --target cursor claude windsurf, which writes each tool's native format from a single versioned source.


Ready to stop re-explaining Swift 6 to every AI tool on your team? Create a free localskills.sh account, publish your Swift rules once, and install them everywhere.

npm install -g @localskills/cli
localskills login
localskills publish