1 minute read

Build a weather app with URLSession and JSON in Swift

Weather is a useful first network-driven app because it combines live data, JSON decoding and a clear user interface without hiding the important engineering decisions. The version in this guide uses Swift, UIKit and URLSession. It also reflects Australian conditions: Sydney often brings humid coastal air, Melbourne can shift from sunshine to rain quickly, Perth is known for dry summer heat, and Brisbane can see intense summer storms. Temperatures should appear in Celsius, with the selected city and local observation time shown beside the reading.

The app will request current conditions from Open-Meteo, decode the response into strongly typed Swift structures, and update a screen built programmatically. The endpoint is free to use for a prototype and needs no API key. The same design can be reused for weekly forecasts, multiple Australian cities or an official Bureau of Meteorology feed later. Keeping networking, decoding and presentation separate turns a small demonstration into an app that can be tested and maintained.

Model the forecast before writing the interface

Open Xcode and create an iOS App project using the UIKit App Lifecycle template. Give the bundle a distinct name, choose Swift as the language and set the interface option to None because this project creates its UIKit views in code. Organise the source files into Models, Networking and ViewControllers folders. Small folders make it easier to see which layer owns each responsibility.

The JSON field names do not always match the names that read well in Swift. temperature_2m becomes temperature, for example, while weather_code becomes weatherCode. Codable’s CodingKeys maps between the two without scattering string keys throughout the application.

struct CurrentWeather: Decodable {
    let time: String
    let temperature: Double
    let humidity: Int
    let weatherCode: Int

    enum CodingKeys: String, CodingKey {
        case time
        case temperature = "temperature_2m"
        case humidity = "relative_humidity_2m"
        case weatherCode = "weather_code"
    }
}

struct ForecastResponse: Decodable {
    let current: CurrentWeather
}

This model represents only the values needed by the first screen. It avoids trying to decode every field in the API response. That narrow contract makes unexpected additions harmless, while missing required fields can still be reported as a decoding problem.

Request live data with URLSession

Build the endpoint with URLComponents rather than pasting strings together. Query parameters carry the latitude, longitude, requested current fields and timezone=auto. Automatic time zones are useful when the same app shows Sydney, Adelaide and Brisbane, whose local observation times differ.

struct WeatherService {
    let session: URLSession

    func currentWeather(
        latitude: Double,
        longitude: Double
    ) async throws -> CurrentWeather {
        guard var components = URLComponents(
            string: "https://api.open-meteo.com/v1/forecast"
        ) else {
            throw WeatherError.invalidURL
        }

        components.queryItems = [
            URLQueryItem(
                name: "latitude",
                value: String(latitude)
            ),
            URLQueryItem(
                name: "longitude",
                value: String(longitude)
            ),
            URLQueryItem(
                name: "current",
                value: "temperature_2m,relative_humidity_2m,weather_code"
            ),
            URLQueryItem(name: "timezone", value: "auto")
        ]

        guard let url = components.url else {
            throw WeatherError.invalidURL
        }

        let (data, response) = try await session.data(from: url)

        guard let httpResponse = response as? HTTPURLResponse else {
            throw WeatherError.invalidResponse
        }

        guard (200..<300).contains(httpResponse.statusCode) else {
            throw WeatherError.server(statusCode: httpResponse.statusCode)
        }

        do {
            let forecast = try JSONDecoder().decode(
                ForecastResponse.self,
                from: data
            )
            return forecast.current
        } catch {
            throw WeatherError.decoding(error)
        }
    }
}

URLSession owns the HTTP request, waits for the response and returns raw data plus response metadata. The async and await keywords keep the asynchronous work readable while the UI remains responsive. Since the service returns a decoded model, the view controller does not need to know that the source happens to be JSON.

Check the HTTP status code before decoding. A 200 response can contain valid JSON, while a 500 response normally contains an error payload. Treating both as forecast data would send the wrong information into the decoder and produce a confusing failure later.

Decode JSON and handle failure states

Swift’s JSONDecoder converts JSON into the Decodable structures created earlier. Decoding is strict by default: a required property of the wrong type causes failure. This strictness is helpful because it reveals a mismatch between the expected API contract and the data received.

enum WeatherError: LocalizedError {
    case invalidURL
    case invalidResponse
    case server(statusCode: Int)
    case decoding(Error)

    var errorDescription: String? {
        switch self {
        case .invalidURL:
            return "The forecast request could not be created."
        case .invalidResponse:
            return "The forecast service returned an unexpected response."
        case .server(let statusCode):
            return "The forecast service returned status code \(statusCode)."
        case .decoding:
            return "The forecast data was not in the expected format."
        }
    }
}

A weather screen should translate technical failures into a state a person can understand. URLError.notConnectedToInternet means the request never reached the service. A non-success status code points to the server, and a decoding failure means the response shape changed or was incomplete. Keep the raw error available in logs, but show a short message in the interface.

Presentation also needs a main-thread boundary. After an async request returns, move UI updates to the main actor or dispatch them to the main queue. This prevents a fast response from trying to change labels while the view controller is being prepared or dismissed.

Build a clear UIKit forecast screen

A weather app does not need a complicated layout to feel useful. A vertical stack view can hold a city label, a large temperature label, a condition label and a smaller details label. Constraints should keep the content readable with Dynamic Type and on larger iPhone screens.

For the main forecast card, keep the visible information small and scannable:

  • The selected Australian city and its local observation time
  • The current temperature in degrees Celsius
  • A plain-language condition translated from the WMO weather code
  • Humidity plus the time of the last successful refresh

Reliability needs three explicit states around that card:

  • An initial loading state with an activity indicator
  • A cached forecast marked as stale when a refresh fails
  • A retry action after connectivity or a server problem ends

Mapping WMO codes deserves its own small component. Code 0 represents clear conditions, 3 means overcast, and code 95 indicates a thunderstorm. A lookup table can turn the numeric value into wording, but severe-weather wording should remain conservative. A forecast generated by Open-Meteo is separate from official warnings issued by Australia’s Bureau of Meteorology, and the interface should not blur that distinction.

Keep the service behind a small protocol so the view controller can receive a mock during development and tests. This also lets the screen be tested with fixed temperatures and weather codes, including difficult states such as 40-degree heat or a storm, without depending on a live response.

Test and prepare the app for real use

Start with a small decoder test. Save a realistic JSON response as a test fixture, decode it into ForecastResponse, and check the temperature, humidity, time and weather code. This catches field-name and type mistakes in seconds. A second test can use a custom URLProtocol to return a controlled status code or malformed body, allowing the interface’s error states to be exercised without making network requests.

Then test the complete flow on a physical device. Check portrait and landscape layouts, slow mobile connections, Wi-Fi loss and the transition between foreground and background. Because Australia has large distances and some regional connections are less consistent, retaining the last successful forecast can be valuable. Display its timestamp clearly so old information is never presented as a live observation.

For an Australian release, make the data provider, attribution, units and update time visible. Avoid suggesting that the app supplies official emergency warnings unless it has an appropriate data source and approval. A short privacy note should also explain whether location coordinates are collected or stored. These details matter to Australian users and help the app meet App Store review expectations for clarity and responsible data use.

The core lesson is to keep the foundation small and explicit: one typed forecast model, one URLSession service, one decoding boundary and one set of UI states. That structure gives a junior developer a testable path from a JSON response to a trustworthy screen.

Updated: