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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge, including async/await
- Familiarity with SwiftData (or Core Data concepts)
- Understanding of XML parsing — RSS and Atom use different tag schemas
- Background App Refresh entitlement — must be enabled in the Signing & Capabilities tab and on the App ID in App Store Connect
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
- RSS vs Atom tag mismatch. RSS uses
<item>,<pubDate>, and<description>. Atom uses<entry>,<updated>, and<summary>/<content>. Your parser must handle both — many real-world feeds mix elements from both specs. - Duplicate articles on refresh. Never use auto-generated IDs as de-dupe keys. Always use the feed's
<guid>or Atom<id>. If the feed omits these, fall back to hashing the article URL. - Background refresh silently disabled. iOS will stop calling your
BGAppRefreshTaskif the user has never opened the app after boot, or if Low Power Mode is on. Always re-schedule inside the task handler and handle theBGTaskScheduler.Error.notPermittedgracefully. - App Store review: missing Background Modes justification. Reviewers will ask why your app needs background refresh. Prepare a short response: "The app fetches RSS feeds in the background so users see fresh articles on launch without waiting." Include this in your review notes when submitting.
- WKWebView and App Transport Security. If a feed links to an
http://article (not https), WKWebView will block the load by default. AddNSAllowsArbitraryLoadsInWebContentto yourInfo.plist— but notNSAllowsArbitraryLoads, which will get your app rejected.
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.