Preview Apps for Lightning-Fast Iteration

SwiftUI previews are great for individual views, but they have limits. Complex navigation flows, multi-screen interactions, and real data loading patterns are hard to capture in a #Preview block. And as your app grows, even building for previews can slow down because Xcode still compiles dependencies you don't need.

Preview apps solve both problems. They're tiny executable targets inside your package that build only the feature you're working on. You get a running app on the simulator in seconds, with real navigation and mock data, without compiling any other feature.

You can find the complete working code for this post on GitHub.

The idea

A preview app is an executable target in your SPM package that depends on a single feature module. It creates a minimal App struct, injects mock services, and presents the feature's root view. Nothing else.

LandmarksPackage/
├── Sources/
│   ├── LandmarksFeature/          # The real feature
│   ├── LandmarksPreviewApp/       # Mini app for Landmarks
│   ├── ReservationsFeature/       # The real feature
│   └── ReservationsPreviewApp/    # Mini app for Reservations

When you select the LandmarksPreviewApp scheme in Xcode and hit Run, it builds Env, Logger, Toolkit, LandmarksApi, LandmarksDomain, and LandmarksFeature. That's it. The entire Reservations stack never compiles.

Adding executable targets

Update your Package.swift with executable product and target entries:

let package = Package(
    name: "LandmarksPackage",
    platforms: [.iOS(.v18)],
    products: [
        .library(name: "LandmarksDomain", targets: ["LandmarksDomain"]),
        .library(name: "LandmarksFeature", targets: ["LandmarksFeature"]),
        .library(name: "ReservationsDomain", targets: ["ReservationsDomain"]),
        .library(name: "ReservationsFeature", targets: ["ReservationsFeature"]),
    ],
    targets: [
        // ... existing targets ...

        // Preview Apps
        .executableTarget(
            name: "LandmarksPreviewApp",
            dependencies: [.landmarksFeature, .landmarksDomain]
        ),
        .executableTarget(
            name: "ReservationsPreviewApp",
            dependencies: [.reservationsFeature, .reservationsDomain]
        ),
    ]
)

Note that preview apps are executableTarget, not library. They produce a runnable binary. They're also not listed in products since nothing else depends on them.

The preview app entry point

Each preview app is a single file:

// Sources/LandmarksPreviewApp/LandmarksPreviewApp.swift

import SwiftUI
import LandmarksDomain
import LandmarksFeature

@main
struct LandmarksPreviewApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                LandmarkListView()
            }
            .environment(\.landmarkService, .mock)
        }
    }
}

That's the entire file. It imports the feature, injects mock services, and runs. When you build this target, SPM only compiles the modules in LandmarksFeature's dependency tree. Everything outside that tree is skipped.

For the Reservations preview app, you inject both services since reservations reference landmarks:

// Sources/ReservationsPreviewApp/ReservationsPreviewApp.swift

import SwiftUI
import LandmarksDomain
import ReservationsDomain
import ReservationsFeature

@main
struct ReservationsPreviewApp: App {
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                ReservationListView()
            }
            .environment(\.reservationService, .mock)
            .environment(\.landmarkService, .mock)
        }
    }
}

The compile time difference

Here's why this matters. On a moderately sized app with six feature modules, building the main app target means compiling everything:

Main App: Common + 6 API + 6 Domain + 6 Feature + App = ~25 modules

A preview app compiles only its slice:

LandmarksPreviewApp: Common + 1 API + 1 Domain + 1 Feature + PreviewApp = ~6 modules

On a clean build, that's roughly 4x fewer modules. On incremental builds (where you're iterating on a single view), SPM only recompiles the changed module and the preview app target. That feedback loop drops from "wait for the app to build" to "it's already running."

Richer preview scenarios

Preview apps aren't limited to the happy path. You can create different launch configurations:

@main
struct LandmarksPreviewApp: App {
    @State private var scenario: Scenario = .normal

    var body: some Scene {
        WindowGroup {
            NavigationStack {
                VStack {
                    Picker("Scenario", selection: $scenario) {
                        ForEach(Scenario.allCases, id: \.self) { s in
                            Text(s.rawValue).tag(s)
                        }
                    }
                    .pickerStyle(.segmented)
                    .padding()

                    LandmarkListView()
                }
            }
            .environment(\.landmarkService, scenario.service)
        }
    }
}

enum Scenario: String, CaseIterable {
    case normal = "Normal"
    case empty = "Empty"
    case error = "Error"

    var service: LandmarkService {
        switch self {
        case .normal:
            return .mock
        case .empty:
            return LandmarkService(
                fetchLandmarks: { [] },
                fetchLandmark: { _ in throw LandmarkError.notFound },
                fetchLandmarksByCategory: { _ in [] },
                toggleFavorite: { _ in throw LandmarkError.notFound }
            )
        case .error:
            return LandmarkService(
                fetchLandmarks: { throw LandmarkError.networkError(
                    URLError(.notConnectedToInternet)
                )},
                fetchLandmark: { _ in throw LandmarkError.networkError(
                    URLError(.notConnectedToInternet)
                )},
                fetchLandmarksByCategory: { _ in throw LandmarkError.networkError(
                    URLError(.notConnectedToInternet)
                )},
                toggleFavorite: { _ in throw LandmarkError.networkError(
                    URLError(.notConnectedToInternet)
                )}
            )
        }
    }
}

This gives you a running app with a segmented control at the top to switch between normal data, empty states, and error states. You can test all three without changing any code. Because services are closures, you can construct any scenario inline.

Preview apps for design work

Preview apps are also great for working with designers. Instead of asking them to build and run the full app, point them at a single scheme:

  1. Open the Xcode project
  2. Select the LandmarksPreviewApp scheme
  3. Hit Run

They get a focused app with just the feature they're reviewing, running on mock data, building in seconds. No backend, no authentication, no setup.

When to create a preview app

Not every module needs a preview app. Create them for:

  • Feature modules with navigation flows - anything more than a single screen
  • Features you're actively iterating on - the compile time savings compound quickly
  • Features that designers need to review - give them a focused, standalone experience

Skip preview apps for:

  • Domain modules - test these with unit tests instead
  • Common utilities - these are too low-level for a visual preview
  • Stable features - if you're not actively changing it, the compile time savings don't matter

Keeping preview apps in sync

Preview apps depend on the same feature modules as your main app. When you update LandmarkListView, the preview app picks up the change automatically. The only maintenance is updating the preview app's service injection if you add new environment dependencies.

A good practice: when you add a new @Entry environment key, add it to the preview apps in the same PR. This keeps them working and documents the feature's dependencies clearly.

The full Package.swift

Here's what the package manifest looks like with preview apps included:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "LandmarksPackage",
    platforms: [.iOS(.v18)],
    products: [
        .library(name: "LandmarksDomain", targets: ["LandmarksDomain"]),
        .library(name: "LandmarksFeature", targets: ["LandmarksFeature"]),
        .library(name: "ReservationsDomain", targets: ["ReservationsDomain"]),
        .library(name: "ReservationsFeature", targets: ["ReservationsFeature"]),
    ],
    targets: [
        // Common layer
        .target(name: "Env"),
        .target(name: "Logger"),
        .target(name: "Toolkit"),

        // Landmarks
        .target(name: "LandmarksApi", dependencies: [.env, .toolkit]),
        .target(name: "LandmarksDomain", dependencies: [.landmarksApi, .env, .toolkit]),
        .target(name: "LandmarksFeature", dependencies: [.landmarksDomain, .env, .toolkit]),

        // Reservations
        .target(name: "ReservationsApi", dependencies: [.env, .toolkit]),
        .target(name: "ReservationsDomain", dependencies: [.reservationsApi, .landmarksDomain, .env, .toolkit]),
        .target(name: "ReservationsFeature", dependencies: [.reservationsDomain, .env, .toolkit]),

        // Preview Apps
        .executableTarget(name: "LandmarksPreviewApp", dependencies: [.landmarksFeature, .landmarksDomain]),
        .executableTarget(name: "ReservationsPreviewApp", dependencies: [.reservationsFeature, .reservationsDomain]),

        // Tests
        .testTarget(name: "LandmarksDomainTests", dependencies: [.landmarksDomain]),
        .testTarget(name: "ReservationsDomainTests", dependencies: [.reservationsDomain]),
    ]
)

Preview apps add zero overhead to your production build. They only compile when you select their scheme.

In the next post, we'll build a shared DesignSystem module that lives in the Common layer. Buttons, cards, loading states, error views - components that every feature uses but nobody should duplicate.