How to Build a News Aggregator App in SwiftUI
A News Aggregator app pulls article feeds from multiple sources, organises them into readable categories, and lets users browse and read stories without leaving your app. It's ideal for indie developers targeting news junkies, niche hobbyists, or any audience that wants one place for curated content.
Prerequisites
- Mac with Xcode 16+ installed
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Solid Swift/SwiftUI basics:
@State,@Environment, async/await - Familiarity with
URLSessionand JSON/XML parsing (RSS feeds are XML) - Optional: a real device for testing background feed refresh (simulator works for most flows)
Architecture overview
The app splits into three layers: a data layer (SwiftData models + a FeedService actor that fetches and parses RSS/JSON feeds), a state layer (an @Observable FeedStore that holds the selected category and triggers refreshes), and a view layer (a category picker, an article list with AsyncImage thumbnails, and a WKWebView-backed reader sheet). The ad SDK sits in the view layer only — banner slots are injected between list rows so they never pollute the model layer.
NewsApp/ ├── NewsAppApp.swift # @main, modelContainer setup ├── Models/ │ ├── Article.swift # @Model — title, url, imageURL, publishedAt, isRead │ └── FeedSource.swift # @Model — name, feedURL, category ├── Services/ │ ├── FeedService.swift # actor: fetchFeed(source:) -> [Article] │ └── RSSParser.swift # XMLParserDelegate wrapper ├── Store/ │ └── FeedStore.swift # @Observable — selectedCategory, refresh() ├── Views/ │ ├── ContentView.swift # TabView or NavigationSplitView root │ ├── ArticleListView.swift # List + AdBannerRow interleaved │ ├── ArticleRowView.swift # AsyncImage + metadata │ ├── ArticleReaderView.swift # WKWebView sheet │ └── AdBannerView.swift # GADBannerView wrapper └── PrivacyInfo.xcprivacy
Step-by-step
1. Project setup and configuration
Create a new "App" project in Xcode 16, choose SwiftUI + SwiftData for storage. Enable the Outgoing Connections (Client) capability under Signing & Capabilities — without it App Transport Security will block your feed URLs in release builds.
// NewsAppApp.swift
import SwiftUI
import SwiftData
@main
struct NewsAppApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Article.self, FeedSource.self])
}
}
2. Define SwiftData models
Two @Model classes form the core: FeedSource owns a category string and RSS URL; Article belongs to a source and tracks read state. SwiftData handles the SQLite backing store automatically.
// Models/FeedSource.swift
import SwiftData
import Foundation
@Model
final class FeedSource {
var name: String
var feedURL: String
var category: String
@Relationship(deleteRule: .cascade) var articles: [Article] = []
init(name: String, feedURL: String, category: String) {
self.name = name
self.feedURL = feedURL
self.category = category
}
}
// Models/Article.swift
@Model
final class Article {
var title: String
var url: String
var imageURL: String?
var summary: String?
var publishedAt: Date
var isRead: Bool = false
var source: FeedSource?
init(title: String, url: String, imageURL: String? = nil,
summary: String? = nil, publishedAt: Date, source: FeedSource? = nil) {
self.title = title
self.url = url
self.imageURL = imageURL
self.summary = summary
self.publishedAt = publishedAt
self.source = source
}
}
3. Category picker and article list UI
A ScrollView chip strip at the top filters articles by category. The @Query macro with a dynamic predicate re-fetches from SwiftData whenever the selected category changes — no manual filtering needed.
// Views/ArticleListView.swift
import SwiftUI
import SwiftData
struct ArticleListView: View {
@Query private var articles: [Article]
@State private var selectedCategory: String = "All"
let categories = ["All", "Technology", "Science", "Business", "Sports"]
init(category: String) {
_selectedCategory = State(initialValue: category)
let predicate: Predicate? = category == "All"
? nil
: #Predicate { $0.source?.category == category }
_articles = Query(
filter: predicate,
sort: \.publishedAt,
order: .reverse
)
}
var body: some View {
List {
ForEach(Array(articles.enumerated()), id: \.element.persistentModelID) { index, article in
Group {
ArticleRowView(article: article)
if (index + 1) % 5 == 0 {
AdBannerView()
.listRowInsets(EdgeInsets())
}
}
}
}
.listStyle(.plain)
.navigationTitle(selectedCategory)
}
}
#Preview {
ArticleListView(category: "All")
.modelContainer(for: [Article.self, FeedSource.self], inMemory: true)
}
4. RSS feed fetching by category (core feature)
FeedService is a Swift actor so concurrent refreshes across categories are data-race-safe. A lightweight XMLParserDelegate wrapper extracts the fields you need without pulling in a third-party library.
// Services/FeedService.swift
import Foundation
actor FeedService {
static let shared = FeedService()
func fetchArticles(from source: FeedSource) async throws -> [ParsedArticle] {
guard let url = URL(string: source.feedURL) else {
throw URLError(.badURL)
}
let (data, response) = try await URLSession.shared.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return RSSParser.parse(data: data)
}
}
// Services/RSSParser.swift
import Foundation
struct ParsedArticle {
let title: String
let url: String
let imageURL: String?
let summary: String?
let publishedAt: Date
}
final class RSSParser: NSObject, XMLParserDelegate {
private var parsed: [ParsedArticle] = []
private var current: [String: String] = [:]
private var activeElement = ""
static func parse(data: Data) -> [ParsedArticle] {
let instance = RSSParser()
let parser = XMLParser(data: data)
parser.delegate = instance
parser.parse()
return instance.parsed
}
func parser(_ parser: XMLParser, didStartElement element: String,
namespaceURI: String?, qualifiedName: String?,
attributes: [String: String] = [:]) {
activeElement = element
if element == "item" { current = [:] }
if element == "enclosure" || element == "media:content" {
current["imageURL"] = attributes["url"]
}
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
current[activeElement, default: ""] += string
}
func parser(_ parser: XMLParser, didEndElement element: String,
namespaceURI: String?, qualifiedName: String?) {
guard element == "item",
let title = current["title"],
let link = current["link"] else { return }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z"
let date = formatter.date(from: current["pubDate"] ?? "") ?? Date()
parsed.append(ParsedArticle(
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
url: link.trimmingCharacters(in: .whitespacesAndNewlines),
imageURL: current["imageURL"],
summary: current["description"],
publishedAt: date
))
}
}
5. FeedStore and pull-to-refresh
FeedStore is an @Observable class injected via the environment. It owns refresh logic, preventing duplicate concurrent fetches with an isRefreshing guard, and inserts new articles into the SwiftData ModelContext.
// Store/FeedStore.swift
import SwiftUI
import SwiftData
import Observation
@Observable
final class FeedStore {
var isRefreshing = false
var lastError: String?
@MainActor
func refresh(sources: [FeedSource], context: ModelContext) async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
await withTaskGroup(of: Void.self) { group in
for source in sources {
group.addTask {
do {
let parsed = try await FeedService.shared.fetchArticles(from: source)
let existingURLs = Set(source.articles.map(\.url))
let newArticles = parsed
.filter { !existingURLs.contains($0.url) }
.map { p in
Article(title: p.title, url: p.url,
imageURL: p.imageURL, summary: p.summary,
publishedAt: p.publishedAt, source: source)
}
await MainActor.run {
newArticles.forEach { context.insert($0) }
}
} catch {
await MainActor.run { self.lastError = error.localizedDescription }
}
}
}
}
try? context.save()
}
}
// Usage in ContentView:
// .refreshable { await store.refresh(sources: sources, context: context) }
6. In-app article reader with WebKit
Wrap WKWebView in a UIViewRepresentable. Present it as a sheet so the user can swipe to dismiss. Mark the article as read on appear so the row styling updates immediately through SwiftData's live query.
// Views/ArticleReaderView.swift
import SwiftUI
import WebKit
struct WebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
return WKWebView(frame: .zero, configuration: config)
}
func updateUIView(_ webView: WKWebView, context: Context) {
webView.load(URLRequest(url: url))
}
}
struct ArticleReaderView: View {
let article: Article
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var modelContext
var body: some View {
NavigationStack {
Group {
if let url = URL(string: article.url) {
WebView(url: url)
} else {
ContentUnavailableView("Invalid URL", systemImage: "link.slash")
}
}
.navigationTitle(article.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Done") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
if let url = URL(string: article.url) {
ShareLink(item: url)
}
}
}
}
.onAppear {
article.isRead = true
try? modelContext.save()
}
}
}
#Preview {
let article = Article(title: "Test Article",
url: "https://example.com",
publishedAt: .now)
ArticleReaderView(article: article)
.modelContainer(for: Article.self, inMemory: true)
}
7. Privacy Manifest (PrivacyInfo.xcprivacy)
Apple requires a Privacy Manifest for any app using certain APIs or third-party SDKs (including ad SDKs). Without it your binary will be rejected at upload time. Add the file to your app target — Xcode 16 has a template under File → New → Privacy Manifest.
<!-- PrivacyInfo.xcprivacy (plist format) -->
<?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>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string> <!-- App's own files in container -->
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array>
<dict>
<key>NSPrivacyCollectedDataType</key>
<string>NSPrivacyCollectedDataTypeOtherDiagnosticData</string>
<key>NSPrivacyCollectedDataTypeLinked</key>
<false/>
<key>NSPrivacyCollectedDataTypeTracking</key>
<true/> <!-- Required if using ad SDK -->
<key>NSPrivacyCollectedDataTypePurposes</key>
<array>
<string>NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising</string>
</array>
</dict>
</array>
<key>NSPrivacyTracking</key>
<true/>
<key>NSPrivacyTrackingDomains</key>
<array>
<string>googleadservices.com</string>
<string>doubleclick.net</string>
</array>
</dict>
</plist>
Common pitfalls
- RSS character encoding blows up XMLParser. Many feeds use ISO-8859-1 even when they declare UTF-8. Always re-encode:
String(data: data, encoding: .isoLatin1).flatMap { $0.data(using: .utf8) } ?? databefore handing toXMLParser. - Duplicate articles after background refresh. SwiftData has no "upsert." Always diff against existing URLs before inserting — failing to do so causes duplicate rows and balloon storage.
- App Store rejection: missing ATT prompt. If you ship any ad SDK that does cross-app tracking, you must call
ATTrackingManager.requestTrackingAuthorizationbefore the first ad is loaded. Skipping it is grounds for rejection under guideline 5.1.2. - WKWebView cookie isolation breaks paywalled articles. Each
WKWebViewinstance gets its own cookie store by default. Share aWKProcessPoolacross instances if you want session persistence within the app. - AsyncImage stalls on redirected image URLs. Some CDN thumbnail URLs issue 301/302 redirects that
AsyncImagefollows correctly, but others return a non-image content-type on the first hop. Cache usingURLCachewith a custom 10 MB disk cache to reduce re-fetches and avoid noticeable latency on scroll.
Adding monetization: Ad-supported
The most practical ad integration for a News Aggregator is Google Mobile Ads (AdMob) or Apple's SKAdNetwork-compatible alternatives. Add the SDK via Swift Package Manager (https://github.com/googleads/swift-package-manager-google-mobile-ads), initialise it once in NewsAppApp.init() with GADMobileAds.sharedInstance().start(), then insert GADBannerView wrapped in a UIViewRepresentable every fifth row in your article list — this placement consistently yields higher eCPMs than interstitials for content-heavy feeds without disrupting the reading flow. Declare all required SKAdNetworkItems in Info.plist (AdMob publishes a current list in their iOS setup docs), and add the NSUserTrackingUsageDescription key so the ATT permission dialog shows a clear, App Review-approved explanation of why ad personalisation benefits the reader.
Shipping this faster with Soarias
Soarias automates the four most time-consuming parts of this build: it scaffolds the SwiftData model layer and FeedService actor from a short description, generates a correctly structured PrivacyInfo.xcprivacy (including ad-SDK tracking domains) so you never hit an upload rejection, sets up fastlane lanes for TestFlight beta and App Store production, and handles all the App Store Connect metadata — screenshots at every required device size, app description, keywords, and version notes — so you're not manually filling forms at 2 a.m.
For an intermediate project like this one, most developers report spending two to three days on boilerplate, Privacy Manifest troubleshooting, and ASC submission paperwork. With Soarias those tasks collapse to under two hours, letting you spend the rest of the week on the parts that differentiate your app: feed curation, the reader experience, and getting your ad placements right.
Related guides
FAQ
Does this work on iOS 16?
The guide targets iOS 17+ because SwiftData, the #Preview macro, and the @Observable macro all require iOS 17. If you need iOS 16 support, replace SwiftData with Core Data, swap @Observable for ObservableObject, and use the legacy PreviewProvider protocol. That adds roughly a day of rework and you lose the @Query convenience.
Do I need a paid Apple Developer account to test?
No — you can run the app on a personal-team device (free account) for local development and feed testing. You need the paid $99/year membership only to distribute via TestFlight, submit to the App Store, or use capabilities like push notifications. Plan to upgrade before you're ready to ship.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), open Xcode Organizer, click Distribute App → App Store Connect → Upload. Then in App Store Connect create a new version, fill in metadata and screenshots, attach the binary, and submit for review. The review typically takes 24–48 hours for a first submission. Soarias automates every step after the archive.
How do I handle paywalled or broken RSS feeds gracefully?
Since this is an intermediate app, invest in proper error states: catch URLError and HTTP 4xx/5xx in FeedService, surface them as a ContentUnavailableView in the list, and let users long-press a source to manually retry or remove it. For paywalled feeds, show only the summary field (already in the RSS <description> tag) and open the full article in WKWebView where the user's existing browser session may already be authenticated.
Last reviewed: 2026-05-11 by the Soarias team.