```html How to Build a Weather App in SwiftUI (2026)

How to Build a Weather App in SwiftUI

A Weather App that shows real-time conditions and a 7-day forecast is one of the most satisfying SwiftUI projects you can ship — it exercises WeatherKit, CoreLocation, Swift Charts, and async/await all in one. This guide is for iOS developers comfortable with Swift who want a production-quality app they can actually put on the App Store.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

The app uses a lightweight MVVM layout. A LocationManager (marked @Observable) wraps CLLocationManager and publishes the device's CLLocation. A WeatherViewModel (also @Observable) reacts to that location, calls WeatherService.shared.weather(for:) from WeatherKit, and exposes CurrentWeather and a [DayWeather] array to the views. SwiftData persists a list of saved WeatherLocation records (name, lat, lon) so the app can show weather for user-bookmarked cities even when GPS is unavailable. Charts renders the high/low temperature bar chart — no third-party dependencies needed.

WeatherApp/
├── WeatherApp.swift              # @main, .modelContainer
├── Models/
│   └── WeatherLocation.swift     # @Model — persisted locations
├── Services/
│   ├── LocationManager.swift     # @Observable CLLocationManager wrapper
│   └── WeatherViewModel.swift    # @Observable WeatherKit + state
├── Views/
│   ├── ContentView.swift         # Root NavigationStack
│   ├── CurrentConditionsView.swift
│   ├── ForecastChartView.swift   # Swift Charts high/low bars
│   ├── DailyForecastRow.swift    # List row for each day
│   └── LocationSearchView.swift  # MKLocalSearch city picker
└── PrivacyInfo.xcprivacy         # Required for App Store

Step-by-step

1. Project setup and WeatherKit entitlement

Create a new iOS App project in Xcode (SwiftUI interface, Swift language, SwiftData storage). Then open Signing & Capabilities, click "+ Capability," and add both WeatherKit and Location When In Use. Add the NSLocationWhenInUseUsageDescription key to Info.plist with a clear user-facing reason string — App Review reads this.

// WeatherApp.swift
import SwiftUI
import SwiftData

@main
struct WeatherApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: WeatherLocation.self)
    }
}

// Info.plist key to add:
// NSLocationWhenInUseUsageDescription
// "Your location is used to show local weather conditions."

2. Data model with SwiftData

Define a WeatherLocation model to persist bookmarked cities. Keeping latitude and longitude as plain Double properties means the model stays Codable-friendly and you can reconstruct a CLLocation anywhere you need one.

// Models/WeatherLocation.swift
import SwiftData
import Foundation

@Model
final class WeatherLocation {
    var name: String
    var latitude: Double
    var longitude: Double
    var addedAt: Date

    init(name: String, latitude: Double, longitude: Double) {
        self.name = name
        self.latitude = latitude
        self.longitude = longitude
        self.addedAt = .now
    }

    var clLocation: CLLocation {
        CLLocation(latitude: latitude, longitude: longitude)
    }
}

3. CoreLocation permission and manager

Wrap CLLocationManager in an @Observable class so views can react to authorization changes and new fixes without needing Combine. Requesting a one-shot location (rather than continuous updates) keeps battery usage minimal and satisfies App Review scrutiny for location access.

// Services/LocationManager.swift
import CoreLocation
import Observation

@Observable
final class LocationManager: NSObject, CLLocationManagerDelegate {
    private let manager = CLLocationManager()
    var location: CLLocation?
    var authorizationStatus: CLAuthorizationStatus = .notDetermined

    override init() {
        super.init()
        manager.delegate = self
        manager.desiredAccuracy = kCLLocationAccuracyKilometer
    }

    func requestLocation() {
        switch manager.authorizationStatus {
        case .notDetermined:
            manager.requestWhenInUseAuthorization()
        case .authorizedWhenInUse, .authorizedAlways:
            manager.requestLocation()
        default:
            break
        }
    }

    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        location = locations.last
    }

    func locationManager(
        _ manager: CLLocationManager,
        didFailWithError error: Error
    ) {
        print("Location error: \(error.localizedDescription)")
    }

    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        authorizationStatus = manager.authorizationStatus
        if authorizationStatus == .authorizedWhenInUse
            || authorizationStatus == .authorizedAlways {
            manager.requestLocation()
        }
    }
}

4. Current conditions view

Build a focused CurrentConditionsView that takes a WeatherKit CurrentWeather value type and renders the condition symbol, temperature, and key metrics. Using .symbolRenderingMode(.multicolor) on SF Symbols gives you weather icons that already carry the right colors — no custom assets needed.

// Views/CurrentConditionsView.swift
import SwiftUI
import WeatherKit

struct CurrentConditionsView: View {
    let weather: CurrentWeather

    var body: some View {
        VStack(spacing: 16) {
            Image(systemName: weather.symbolName)
                .font(.system(size: 80))
                .symbolRenderingMode(.multicolor)

            Text(weather.temperature.formatted())
                .font(.system(size: 64, weight: .thin))

            Text(weather.condition.description)
                .font(.title3)
                .foregroundStyle(.secondary)

            HStack(spacing: 32) {
                Label(
                    weather.humidity.formatted(.percent.precision(.fractionLength(0))),
                    systemImage: "humidity.fill"
                )
                Label(
                    weather.wind.speed.formatted(),
                    systemImage: "wind"
                )
                Label(
                    weather.uvIndex.value.formatted(),
                    systemImage: "sun.max.fill"
                )
            }
            .font(.subheadline)
            .foregroundStyle(.secondary)
        }
        .padding(.vertical, 24)
    }
}

#Preview {
    // WeatherKit types are not constructable in previews;
    // use a placeholder view for design iteration.
    VStack(spacing: 16) {
        Image(systemName: "cloud.sun.fill")
            .font(.system(size: 80))
            .symbolRenderingMode(.multicolor)
        Text("21°C")
            .font(.system(size: 64, weight: .thin))
        Text("Partly Cloudy")
            .foregroundStyle(.secondary)
    }
    .padding()
}

5. WeatherKit service and 7-day forecast

WeatherService.shared.weather(for:) returns a Weather value containing both current conditions and a daily forecast. Wrap it in an @Observable view-model so any view can call fetchWeather(for:) and observe loading state without boilerplate.

// Services/WeatherViewModel.swift
import WeatherKit
import CoreLocation
import Observation

@Observable
final class WeatherViewModel {
    var currentWeather: CurrentWeather?
    var dailyForecast: [DayWeather] = []
    var isLoading = false
    var errorMessage: String?

    private let service = WeatherService.shared

    @MainActor
    func fetchWeather(for location: CLLocation) async {
        isLoading = true
        errorMessage = nil
        do {
            let weather = try await service.weather(for: location)
            currentWeather = weather.currentWeather
            dailyForecast = Array(weather.dailyForecast.forecast.prefix(7))
        } catch {
            errorMessage = "Could not load weather: \(error.localizedDescription)"
        }
        isLoading = false
    }
}

// Views/ContentView.swift
import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var locationManager = LocationManager()
    @State private var viewModel = WeatherViewModel()

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView("Fetching weather…")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                } else if let current = viewModel.currentWeather {
                    ScrollView {
                        VStack(alignment: .leading, spacing: 20) {
                            CurrentConditionsView(weather: current)
                            if !viewModel.dailyForecast.isEmpty {
                                Text("7-Day Forecast")
                                    .font(.headline)
                                    .padding(.horizontal)
                                ForecastChartView(forecast: viewModel.dailyForecast)
                                ForEach(viewModel.dailyForecast, id: \.date) { day in
                                    DailyForecastRow(day: day)
                                }
                            }
                        }
                    }
                } else if let error = viewModel.errorMessage {
                    ContentUnavailableView(
                        error,
                        systemImage: "cloud.slash.fill"
                    )
                } else {
                    ContentUnavailableView(
                        "Allow Location Access",
                        systemImage: "location.circle",
                        description: Text("Tap Allow in the system prompt to see local weather.")
                    )
                }
            }
            .navigationTitle("Weather")
            .task {
                locationManager.requestLocation()
            }
            .onChange(of: locationManager.location) { _, newLocation in
                guard let loc = newLocation else { return }
                Task { await viewModel.fetchWeather(for: loc) }
            }
        }
    }
}

#Preview {
    ContentView()
}

6. Swift Charts forecast visualization

A BarMark with yStart/yEnd is the right primitive for a daily temperature range chart — each bar stretches from the daily low to the daily high. A LinearGradient fill (cool blue at the bottom, warm orange at the top) communicates temperature direction without any legend.

// Views/ForecastChartView.swift
import SwiftUI
import Charts
import WeatherKit

struct ForecastChartView: View {
    let forecast: [DayWeather]

    var body: some View {
        Chart(forecast, id: \.date) { day in
            BarMark(
                x: .value("Day", day.date, unit: .day),
                yStart: .value("Low", day.lowTemperature.converted(to: .celsius).value),
                yEnd: .value("High", day.highTemperature.converted(to: .celsius).value)
            )
            .clipShape(Capsule())
            .foregroundStyle(
                LinearGradient(
                    colors: [.blue, .orange],
                    startPoint: .bottom,
                    endPoint: .top
                )
            )
        }
        .chartXAxis {
            AxisMarks(values: .stride(by: .day)) { _ in
                AxisValueLabel(format: .dateTime.weekday(.abbreviated))
            }
        }
        .chartYAxis {
            AxisMarks { value in
                AxisGridLine()
                AxisValueLabel {
                    if let v = value.as(Double.self) {
                        Text("\(Int(v))°")
                            .font(.caption2)
                    }
                }
            }
        }
        .frame(height: 180)
        .padding(.horizontal)
    }
}

// Views/DailyForecastRow.swift
import SwiftUI
import WeatherKit

struct DailyForecastRow: View {
    let day: DayWeather

    var body: some View {
        HStack {
            Text(day.date.formatted(.dateTime.weekday(.wide)))
                .frame(width: 110, alignment: .leading)
            Image(systemName: day.symbolName)
                .symbolRenderingMode(.multicolor)
                .frame(width: 28)
            Spacer()
            Text(day.lowTemperature.formatted())
                .foregroundStyle(.blue)
            Text("·")
                .foregroundStyle(.secondary)
            Text(day.highTemperature.formatted())
                .foregroundStyle(.orange)
        }
        .font(.subheadline)
        .padding(.horizontal)
        .padding(.vertical, 6)
    }
}

7. Privacy Manifest (PrivacyInfo.xcprivacy)

App Store Connect rejects apps that use CoreLocation or networking without a PrivacyInfo.xcprivacy file. Add the file to your app target (File → New → File → App Privacy) and declare both location access and any required reason APIs. This is non-negotiable for weather apps.

<!-- PrivacyInfo.xcprivacy (XML plist) -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypePreciseLocation</string>
      <key>NSPrivacyCollectedDataTypeLinked</key>
      <false/>
      <key>NSPrivacyCollectedDataTypeTracking</key>
      <false/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array>
        <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
      </array>
    </dict>
  </array>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>CA92.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Ad-supported

The most practical ad network for SwiftUI apps on iOS is Google AdMob, integrated via the GoogleMobileAds Swift package. Add the SDK via Swift Package Manager (https://github.com/googleads/swift-package-manager-google-mobile-ads), initialize it in your App.init with GADMobileAds.sharedInstance().start(completionHandler: nil), and embed a GADBannerView or interstitial at natural breakpoints — for a weather app, a banner below the forecast chart or a full-screen interstitial when the user switches between saved cities works well. You must also add the NSUserTrackingUsageDescription key to Info.plist and call ATTrackingManager.requestTrackingAuthorization on first launch if you want personalized ads; without ATT consent, AdMob will serve limited ads at lower eCPMs. Make sure your PrivacyInfo.xcprivacy and App Store privacy nutrition labels reflect data collection for advertising purposes — failing to do so is an increasingly common reason for App Review rejection in 2026.

Shipping this faster with Soarias

Soarias handles the parts of this guide that aren't actually writing Swift code: it scaffolds the full project file tree (including the PrivacyInfo.xcprivacy pre-filled with the right CoreLocation reason codes), configures your fastlane Fastfile for TestFlight and App Store distribution, generates the required App Store screenshots at every device size using your live simulator, and submits the build to App Store Connect with the correct metadata — all from Claude Code in your terminal, without you touching App Store Connect's web UI.

For an intermediate project like this Weather App, most developers spend two to three evenings just on entitlement setup, fastlane configuration, and the App Store submission form. Soarias collapses that to a single /ship command. The WeatherKit entitlement wiring and Privacy Manifest are the two steps that most reliably cause first-time rejections — Soarias templates both correctly out of the box, which on its own is worth the $79 one-time price.

Related guides

FAQ

Does this work on iOS 16?

WeatherKit, @Observable, and the #Preview macro all require iOS 17+. If you need iOS 16 support, replace @Observable with @ObservableObject/@Published, use PreviewProvider instead of #Preview, and drop WeatherKit in favor of a third-party REST API like Open-Meteo or Tomorrow.io. Charts works from iOS 16, so the visualization layer is fine as-is.

Do I need a paid Apple Developer account to test on device?

A free Apple ID can sideload apps to a personal device for seven days at a time, but the WeatherKit entitlement cannot be enabled without a paid Developer Program membership. Without it, every WeatherKit call returns a 401 error. You cannot work around this in the Simulator either — WeatherKit requires a signed entitlement from a paid account. There is no free tier for WeatherKit access.

How do I add this to the App Store?

After testing on TestFlight, create an App Store listing in App Store Connect (or let Soarias create it for you with /ship), fill in the required metadata (description, keywords, category, age rating, privacy nutrition labels), upload at least one screenshot per device class, set the pricing, and click Submit for Review. First submissions for apps using location typically take two to four business days for review. Make sure your location purpose string in Info.plist is specific — generic strings reliably trigger rejection.

WeatherKit has a free tier — what happens if I exceed it?

Apple's WeatherKit free tier includes 500,000 API calls per month per developer account. For a small to medium app this is ample, but if your app grows, each additional call is billed at $0.0002. You can monitor usage in App Store Connect under Analytics → WeatherKit. To stay within limits, cache the last-fetched weather response in memory and only re-fetch when the location changes significantly or the cached data is older than 30 minutes — Date().timeIntervalSince(lastFetchDate) > 1800 is a reasonable threshold.

Last reviewed: 2026-05-12 by the Soarias team.

```