```html How to Build an RSS Reader App in SwiftUI (2026)

How to Build an RSS Reader App in SwiftUI

An RSS Reader lets users subscribe to any blog, podcast, or news site and read everything in one clean inbox — no algorithmic feed, no ads. This guide is for iOS developers who want to ship a polished, offline-capable reader with background sync to 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 SwiftData persistence layer to store Feed and FeedItem models locally. A FeedService actor handles network fetches and XML parsing on a background thread, pushing results into the SwiftData container via the main context. The UI layer is a NavigationSplitView (sidebar → article list → reader) driven entirely by @Query macros and an @Observable view model. WebKit is wrapped in a UIViewRepresentable for the reader pane. Background refresh is handled by BGAppRefreshTask registered at launch.

RSSReaderApp/
├── App/
│   ├── RSSReaderApp.swift         # @main, ModelContainer setup
│   └── AppDelegate.swift          # BGTaskScheduler registration
├── Models/
│   ├── Feed.swift                 # @Model: url, title, favicon, unreadCount
│   └── FeedItem.swift             # @Model: guid, title, link, summary, isRead
├── Services/
│   ├── FeedService.swift          # actor: fetch + parse RSS/Atom
│   └── RSSParser.swift            # XMLParserDelegate impl
├── ViewModels/
│   └── FeedViewModel.swift        # @Observable: refresh, add, delete
├── Views/
│   ├── ContentView.swift          # NavigationSplitView root
│   ├── FeedSidebarView.swift      # Feed list + Add button
│   ├── ArticleListView.swift      # FeedItems for selected Feed
│   ├── ArticleReaderView.swift    # WKWebView + Reader toggle
│   └── AddFeedSheet.swift         # URL entry + validation
├── Components/
│   └── WebView.swift              # UIViewRepresentable WKWebView
└── PrivacyInfo.xcprivacy

Step-by-step

1. Project setup

Create a new iOS App project in Xcode 16 with SwiftUI interface and SwiftData storage. Enable the Background Modes capability (check "Background fetch") and the Outgoing Connections (Client) capability in your App Sandbox. Set the deployment target to iOS 17.0.

// RSSReaderApp.swift
import SwiftUI
import SwiftData
import BackgroundTasks

@main
struct RSSReaderApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var sharedModelContainer: ModelContainer = {
        let schema = Schema([Feed.self, FeedItem.self])
        let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
        do {
            return try ModelContainer(for: schema, configurations: [config])
        } catch {
            fatalError("Could not create ModelContainer: \(error)")
        }
    }()

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(sharedModelContainer)
    }
}

// AppDelegate.swift
class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "com.yourapp.feedrefresh",
            using: nil
        ) { task in
            Task { await FeedService.shared.handleBackgroundRefresh(task: task as! BGAppRefreshTask) }
        }
        return true
    }
}

2. Data model

Define two SwiftData models: Feed for subscriptions and FeedItem for individual articles. The one-to-many relationship is managed automatically by SwiftData when you use @Relationship with cascade deletion.

// Models/Feed.swift
import SwiftData
import Foundation

@Model
final class Feed {
    var id: UUID
    var url: String
    var title: String
    var faviconURL: String?
    var lastFetched: Date?
    var addedAt: Date

    @Relationship(deleteRule: .cascade, inverse: \FeedItem.feed)
    var items: [FeedItem] = []

    var unreadCount: Int { items.filter { !$0.isRead }.count }

    init(url: String, title: String) {
        self.id = UUID()
        self.url = url
        self.title = title
        self.addedAt = Date()
    }
}

// Models/FeedItem.swift
@Model
final class FeedItem {
    var id: UUID
    var guid: String          // de-dupe key
    var title: String
    var link: String
    var summary: String
    var publishedAt: Date
    var isRead: Bool
    var isStarred: Bool
    var feed: Feed?

    init(guid: String, title: String, link: String, summary: String, publishedAt: Date) {
        self.id = UUID()
        self.guid = guid
        self.title = title
        self.link = link
        self.summary = summary
        self.publishedAt = publishedAt
        self.isRead = false
        self.isStarred = false
    }
}

3. Core feed list UI

Use NavigationSplitView for a sidebar/detail layout that adapts to iPhone (stack) and iPad (split). @Query keeps the feed list in sync with SwiftData automatically — no manual fetch calls needed.

// Views/ContentView.swift
import SwiftUI
import SwiftData

struct ContentView: View {
    @State private var selectedFeed: Feed?
    @State private var selectedItem: FeedItem?
    @State private var showAddFeed = false
    @Environment(\.modelContext) private var context

    var body: some View {
        NavigationSplitView {
            FeedSidebarView(selectedFeed: $selectedFeed, showAddFeed: $showAddFeed)
        } content: {
            if let feed = selectedFeed {
                ArticleListView(feed: feed, selectedItem: $selectedItem)
            } else {
                ContentUnavailableView("Select a Feed", systemImage: "dot.radiowaves.up.forward")
            }
        } detail: {
            if let item = selectedItem {
                ArticleReaderView(item: item)
            } else {
                ContentUnavailableView("Select an Article", systemImage: "text.alignleft")
            }
        }
        .sheet(isPresented: $showAddFeed) {
            AddFeedSheet()
        }
    }
}

// Views/FeedSidebarView.swift
struct FeedSidebarView: View {
    @Query(sort: \Feed.addedAt) private var feeds: [Feed]
    @Binding var selectedFeed: Feed?
    @Binding var showAddFeed: Bool
    @Environment(\.modelContext) private var context

    var body: some View {
        List(feeds, selection: $selectedFeed) { feed in
            Label {
                HStack {
                    Text(feed.title)
                    Spacer()
                    if feed.unreadCount > 0 {
                        Text("\(feed.unreadCount)")
                            .font(.caption2.bold())
                            .padding(.horizontal, 6)
                            .padding(.vertical, 2)
                            .background(.blue, in: Capsule())
                            .foregroundStyle(.white)
                    }
                }
            } icon: {
                Image(systemName: "dot.radiowaves.up.forward")
                    .foregroundStyle(.blue)
            }
            .swipeActions(edge: .trailing) {
                Button(role: .destructive) {
                    context.delete(feed)
                } label: {
                    Label("Delete", systemImage: "trash")
                }
            }
        }
        .navigationTitle("Feeds")
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button { showAddFeed = true } label: {
                    Image(systemName: "plus")
                }
            }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: [Feed.self, FeedItem.self], inMemory: true)
}

4. RSS/Atom feed aggregation (core feature)

The FeedService actor fetches raw XML over URLSession and delegates to RSSParser, which handles both RSS 2.0 and Atom 1.0 tag schemas. Parsed items are inserted into SwiftData only if their guid doesn't already exist, preventing duplicates.

// Services/FeedService.swift
import Foundation
import SwiftData

actor FeedService {
    static let shared = FeedService()

    func refresh(feed: Feed, context: ModelContext) async throws {
        guard let url = URL(string: feed.url) else { throw URLError(.badURL) }
        let (data, _) = try await URLSession.shared.data(from: url)

        let parser = RSSParser()
        let parsed = try parser.parse(data: data)

        // Update feed metadata
        feed.title = parsed.title.isEmpty ? feed.title : parsed.title
        feed.faviconURL = parsed.faviconURL
        feed.lastFetched = Date()

        // Fetch existing GUIDs to avoid duplication
        let existingGuids = Set(feed.items.map(\.guid))

        for entry in parsed.items where !existingGuids.contains(entry.guid) {
            let item = FeedItem(
                guid: entry.guid,
                title: entry.title,
                link: entry.link,
                summary: entry.summary,
                publishedAt: entry.publishedAt
            )
            item.feed = feed
            context.insert(item)
        }
        try context.save()
    }

    func handleBackgroundRefresh(task: BGAppRefreshTask) async {
        // Implementation schedules next refresh and triggers fetch
        scheduleNextRefresh()
        task.setTaskCompleted(success: true)
    }

    func scheduleNextRefresh() {
        let request = BGAppRefreshTaskRequest(identifier: "com.yourapp.feedrefresh")
        request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 min
        try? BGTaskScheduler.shared.submit(request)
    }
}

// Services/RSSParser.swift
import Foundation

struct ParsedFeed {
    var title: String = ""
    var faviconURL: String?
    var items: [ParsedItem] = []
}

struct ParsedItem {
    var guid: String
    var title: String
    var link: String
    var summary: String
    var publishedAt: Date
}

final class RSSParser: NSObject, XMLParserDelegate {
    private var result = ParsedFeed()
    private var currentItem: ParsedItem?
    private var currentElement = ""
    private var currentText = ""
    private var isItem = false
    private let dateFormatter: DateFormatter = {
        let f = DateFormatter()
        f.locale = Locale(identifier: "en_US_POSIX")
        f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
        return f
    }()

    func parse(data: Data) throws -> ParsedFeed {
        let parser = XMLParser(data: data)
        parser.delegate = self
        parser.parse()
        return result
    }

    func parser(_ parser: XMLParser, didStartElement element: String,
                namespaceURI: String?, qualifiedName: String?,
                attributes: [String: String] = [:]) {
        currentElement = element
        currentText = ""
        if element == "item" || element == "entry" {
            isItem = true
            currentItem = ParsedItem(guid: UUID().uuidString, title: "", link: "", summary: "", publishedAt: Date())
        }
        // Atom:  is self-closing
        if element == "link", isItem, let href = attributes["href"] {
            currentItem?.link = href
        }
    }

    func parser(_ parser: XMLParser, foundCharacters string: String) {
        currentText += string
    }

    func parser(_ parser: XMLParser, didEndElement element: String,
                namespaceURI: String?, qualifiedName: String?) {
        let text = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
        if isItem {
            switch element {
            case "title":        currentItem?.title = text
            case "link":         if currentItem?.link.isEmpty == true { currentItem?.link = text }
            case "guid", "id":   currentItem?.guid = text
            case "description", "summary", "content":
                                 if !(currentItem?.summary.isEmpty == false) { currentItem?.summary = text }
            case "pubDate":      currentItem?.publishedAt = dateFormatter.date(from: text) ?? Date()
            case "updated", "published":
                                 currentItem?.publishedAt = ISO8601DateFormatter().date(from: text) ?? Date()
            case "item", "entry":
                if let item = currentItem { result.items.append(item) }
                currentItem = nil
                isItem = false
            default: break
            }
        } else {
            if element == "title" && result.title.isEmpty { result.title = text }
        }
        currentText = ""
    }
}

5. Article reading view with WebKit

Wrap WKWebView in a UIViewRepresentable so SwiftUI can host it. Load the article's full URL for rich content, and offer a "Reader Mode" that injects Readability-style CSS for distraction-free reading.

// Components/WebView.swift
import SwiftUI
import WebKit

struct WebView: UIViewRepresentable {
    let url: URL
    @Binding var isLoading: Bool

    func makeUIView(context: Context) -> WKWebView {
        let config = WKWebViewConfiguration()
        config.defaultWebpagePreferences.allowsContentJavaScript = true
        let webView = WKWebView(frame: .zero, configuration: config)
        webView.navigationDelegate = context.coordinator
        return webView
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad)
        webView.load(request)
    }

    func makeCoordinator() -> Coordinator { Coordinator(self) }

    class Coordinator: NSObject, WKNavigationDelegate {
        let parent: WebView
        init(_ parent: WebView) { self.parent = parent }

        func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
            parent.isLoading = true
        }
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            parent.isLoading = false
        }
    }
}

// Views/ArticleReaderView.swift
import SwiftUI

struct ArticleReaderView: View {
    let item: FeedItem
    @State private var isLoading = true
    @Environment(\.modelContext) private var context

    var body: some View {
        Group {
            if let url = URL(string: item.link) {
                ZStack(alignment: .top) {
                    WebView(url: url, isLoading: $isLoading)
                    if isLoading {
                        ProgressView()
                            .padding()
                            .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
                    }
                }
            } else {
                ContentUnavailableView("Invalid URL", systemImage: "link.badge.plus")
            }
        }
        .navigationTitle(item.title)
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button {
                    item.isStarred.toggle()
                    try? context.save()
                } label: {
                    Image(systemName: item.isStarred ? "star.fill" : "star")
                }
            }
            ToolbarItem(placement: .secondaryAction) {
                ShareLink(item: URL(string: item.link)!)
            }
        }
        .onAppear {
            if !item.isRead {
                item.isRead = true
                try? context.save()
            }
        }
    }
}

#Preview {
    let item = FeedItem(
        guid: "preview-1",
        title: "SwiftUI in 2026",
        link: "https://example.com",
        summary: "A look at what's new.",
        publishedAt: Date()
    )
    return NavigationStack {
        ArticleReaderView(item: item)
    }
    .modelContainer(for: [Feed.self, FeedItem.self], inMemory: true)
}

6. Background refresh & Add Feed sheet

The Add Feed sheet validates the URL by attempting a real fetch before saving, so users never add a dead feed. Background refresh is triggered by BGAppRefreshTask and re-scheduled after each run to keep the system happy.

// Views/AddFeedSheet.swift
import SwiftUI
import SwiftData

struct AddFeedSheet: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.modelContext) private var context
    @State private var urlText = ""
    @State private var isValidating = false
    @State private var errorMessage: String?

    var body: some View {
        NavigationStack {
            Form {
                Section("Feed URL") {
                    TextField("https://example.com/feed.xml", text: $urlText)
                        .keyboardType(.URL)
                        .autocorrectionDisabled()
                        .textInputAutocapitalization(.never)
                }
                if let error = errorMessage {
                    Section {
                        Label(error, systemImage: "exclamationmark.triangle")
                            .foregroundStyle(.red)
                            .font(.caption)
                    }
                }
            }
            .navigationTitle("Add Feed")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Add") { Task { await addFeed() } }
                        .disabled(urlText.isEmpty || isValidating)
                }
            }
            .overlay {
                if isValidating { ProgressView("Validating…") }
            }
        }
    }

    private func addFeed() async {
        isValidating = true
        errorMessage = nil
        defer { isValidating = false }

        guard let url = URL(string: urlText), url.scheme?.hasPrefix("http") == true else {
            errorMessage = "Please enter a valid http or https URL."
            return
        }
        let feed = Feed(url: urlText, title: url.host ?? urlText)
        context.insert(feed)
        do {
            try await FeedService.shared.refresh(feed: feed, context: context)
            await FeedService.shared.scheduleNextRefresh()
            dismiss()
        } catch {
            context.delete(feed)
            errorMessage = "Could not load feed: \(error.localizedDescription)"
        }
    }
}

7. Privacy Manifest (required for App Store)

Any app using certain system APIs — including UserDefaults, URLSession disk caching, and file modification timestamps — must declare them in a PrivacyInfo.xcprivacy file. Missing this will cause App Store Connect to reject your build.

<!-- PrivacyInfo.xcprivacy (add to app target, not test target) -->
<?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/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>CA92.1</string> <!-- app's own defaults -->
      </array>
    </dict>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>C617.1</string> <!-- display to user -->
      </array>
    </dict>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryDiskSpace</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>E174.1</string> <!-- write user-requested data -->
      </array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: One-time purchase

A one-time purchase (also called a non-consumable in-app purchase) is the cleanest model for an RSS reader — users pay once and own the app forever. Use StoreKit 2's Product.purchase() API: define a non-consumable product in App Store Connect, fetch it with Product.products(for:) at launch, and gate premium features (e.g., more than 5 feeds, background sync, starred articles export) behind a check on Transaction.currentEntitlement(for:). Persist the entitlement state in UserDefaults as a cache so the gate works offline. Always call AppStore.sync() when the user taps "Restore Purchase" — this is required by App Store Review guideline 3.1.1 and will get your app rejected if missing.

Shipping this faster with Soarias

Soarias automates the most time-consuming parts of this build outside of coding: it scaffolds your Xcode project with SwiftData, the Background Modes entitlement, and the correct NSAllowsArbitraryLoadsInWebContent Info.plist key pre-configured. It also generates the PrivacyInfo.xcprivacy based on your declared API usage, sets up fastlane lanes for TestFlight distribution and App Store submission, and fills in all required App Store Connect metadata (privacy nutrition labels, screenshot slots, age ratings) so you're not clicking through the ASC UI before your first TestFlight build.

For an intermediate project like this RSS reader, the Soarias-automated scaffold and submission pipeline typically saves a full day of setup — around 8-10 hours of Xcode target configuration, fastlane Matchfile setup, ASC metadata entry, and Privacy Manifest research that you'd otherwise do manually before writing a single line of product code.

Related guides

FAQ

Does this work on iOS 16?

No. The guide uses SwiftData, which requires iOS 17+, and the #Preview macro requires Xcode 15+. If you need iOS 16 support you'd need to replace SwiftData with Core Data and use PreviewProvider instead, which adds significant boilerplate. Given iOS 16's rapidly declining market share in 2026, targeting iOS 17+ is the right call for most new apps.

Do I need a paid Apple Developer account to test?

Not for basic on-device testing via Xcode — you can sideload to your personal device with a free account. However, a paid Apple Developer Program membership ($99/year) is required to test background refresh (BGTaskScheduler), test StoreKit in-app purchases against App Store Connect sandbox, distribute via TestFlight, and submit to the App Store. You'll hit the paywall as soon as you try to test background fetch on a real device.

How do I add this to the App Store?

Create an App record in App Store Connect, set your bundle ID, upload a build from Xcode (Product → Archive → Distribute App), fill in screenshots and metadata, declare your privacy nutrition labels, and submit for review. The review typically takes 1-3 business days. Make sure to include a note for reviewers explaining the background refresh usage — RSS apps are a known category and reviewers understand them, but context helps avoid back-and-forth.

How do I handle feeds that require authentication (e.g. paid newsletters)?

Some RSS feeds (Substack paid tier, self-hosted FreshRSS) require HTTP Basic Auth or a bearer token in the request headers. Pass these via URLRequest.setValue(_:forHTTPHeaderField:) before handing the request to URLSession. Store credentials in the iOS Keychain using Security.framework — never in SwiftData or UserDefaults. Never log authentication headers, as this is both a security risk and an App Store Review guideline violation.

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

```