How to Build a Dream Journal App in SwiftUI
A Dream Journal app lets users capture nightly dreams right after waking, tag them for recurring patterns, and review mood trends with Swift Charts. It's a great beginner project: entirely local-first, no backend required, and genuinely useful on day one.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iPhone for testing TextEditor keyboard behavior — Simulator has focus quirks that don't reproduce on device
Architecture overview
The app is entirely local-first: SwiftData persists DreamEntry records on-device with no server. Two tabs handle reading — a searchable list and a Charts insights view — and one modal sheet handles writing. State flows through @Query and @Environment(\.modelContext); no view model layer is needed at this complexity level.
DreamJournal/ ├── Models/ │ └── DreamEntry.swift (@Model — tags, mood, isLucid) ├── Views/ │ ├── ContentView.swift (TabView host) │ ├── NewDreamView.swift (entry sheet with tag chips) │ └── InsightsView.swift (Swift Charts — mood over time) └── DreamJournalApp.swift (modelContainer setup)
Step-by-step
1. Data model
Define DreamEntry as a SwiftData @Model — storing tags as a plain [String] is the right call at this scale; avoid a separate Tag entity until you need cross-entry queries.
import SwiftData
import Foundation
@Model
final class DreamEntry {
var id: UUID = UUID()
var title: String = ""
var content: String = ""
var date: Date = .now
var tags: [String] = []
var mood: Int = 3 // 1–5
var isLucid: Bool = false
init(title: String, content: String,
tags: [String] = [], mood: Int = 3, isLucid: Bool = false) {
self.title = title; self.content = content
self.tags = tags; self.mood = mood; self.isLucid = isLucid
}
}
2. Core UI — list and Charts insights tab
A TabView gives users two modes: a searchable reverse-chronological list of dreams and a Swift Charts bar chart of average mood by week.
struct ContentView: View {
@Query(sort: \DreamEntry.date, order: .reverse) private var dreams: [DreamEntry]
@State private var showingNew = false
var body: some View {
TabView {
NavigationStack {
List(dreams) { d in
NavigationLink(destination: DreamDetailView(dream: d)) {
VStack(alignment: .leading, spacing: 2) {
Text(d.title.isEmpty ? "Untitled" : d.title).font(.headline)
Text(d.date, style: .date).font(.caption).foregroundStyle(.secondary)
}
}
}
.navigationTitle("Dreams")
.toolbar {
Button { showingNew = true } label: {
Image(systemName: "square.and.pencil")
}
}
.sheet(isPresented: $showingNew) { NewDreamView() }
}
.tabItem { Label("Journal", systemImage: "moon.zzz") }
InsightsView(dreams: dreams)
.tabItem { Label("Insights", systemImage: "chart.bar") }
}
}
}
3. Dream recording with tags
The entry sheet combines a TextEditor for free-form content with a scrollable chip row — pressing Return or tapping "Add" appends a tag; tapping a chip removes it.
struct NewDreamView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var title = ""; @State private var content = ""
@State private var tagInput = ""; @State private var tags: [String] = []
var body: some View {
NavigationStack {
Form {
Section("Dream") {
TextField("Title", text: $title)
TextEditor(text: $content).frame(minHeight: 100)
}
Section("Tags") {
HStack {
TextField("Add tag…", text: $tagInput).onSubmit(addTag)
Button("Add", action: addTag)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) { ForEach(tags, id: \.self) { tag in
Text(tag).font(.caption)
.padding(.horizontal, 10).padding(.vertical, 4)
.background(.purple.opacity(0.15)).clipShape(Capsule())
.onTapGesture { tags.removeAll { $0 == tag } }
}}.padding(.vertical, 2)
}
}
}
.navigationTitle("New Dream")
.toolbar { ToolbarItem(placement: .confirmationAction) {
Button("Save") {
context.insert(DreamEntry(title: title, content: content, tags: tags))
dismiss()
}
}}
}
}
private func addTag() {
let t = tagInput.trimmingCharacters(in: .whitespaces)
guard !t.isEmpty, !tags.contains(t) else { return }
tags.append(t); tagInput = ""
}
}
4. Privacy Manifest (PrivacyInfo.xcprivacy)
Add a Privacy Manifest to declare that your app accesses UserDefaults — used internally by SwiftData — or Apple's servers will automatically reject your binary before it reaches a human reviewer.
<?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></array>
</dict>
</array>
</dict>
</plist>
In Xcode: File → New → File from Template → Privacy Manifest. Make sure the file is added to your app target.
Common pitfalls
- TextEditor traps the keyboard. Add
.scrollDismissesKeyboard(.interactively)to the enclosingScrollViewso users can swipe to dismiss without hunting for a Done button. - Tag filtering breaks with @Query #Predicate. SwiftData cannot filter on
[String]array properties inside a#Predicateblock. Fetch all dreams with@Queryand filter tags in a computed property on the result array instead. - Missing Privacy Manifest causes automatic rejection. SwiftData internally touches
UserDefaults, which requires a manifest declaration. The rejection arrives as an automated email before any human reviewer sees your app — addPrivacyInfo.xcprivacybefore your first upload. - "Dream interpretation" in your App Store metadata raises the review bar. Framing the app as analyzing, predicting, or interpreting dreams psychologically can trigger extended review. Keep your description focused on journaling and recording.
- Long dream text slows list rendering. Avoid loading the full
contentstring in list rows — display only the first ~80 characters and defer full text to the detail view.
Adding monetization: One-time purchase
Use StoreKit 2 to offer a single non-consumable product (e.g. com.yourapp.dreamjournal.pro) that unlocks premium features: the Charts insights tab, CSV export, and unlimited tag colors. Fetch the product with Product.products(for:), show a paywall sheet when a free-tier limit is hit, and call product.purchase() to complete the transaction. On launch, check Transaction.currentEntitlement(for: productID) to restore access automatically — StoreKit validates receipts locally via signed JWS, so no server is involved. Register the product in App Store Connect under Monetization → In-App Purchases before uploading your first build, or review will reject the purchase flow.
Shipping this faster with Soarias
Soarias scaffolds the SwiftData model, view hierarchy, and StoreKit paywall from a plain-English prompt — handling the boilerplate in steps 1–3 before you write a line of feature code. It also auto-generates the PrivacyInfo.xcprivacy manifest, wires up fastlane with your App Store Connect API key, captures required screenshots in all device sizes, and uploads metadata and builds to ASC directly.
For a beginner project like this, the scaffolding and submission pipeline typically costs 6–10 hours manually. Soarias compresses that to under an hour, which means you can have a live TestFlight link the same day you finish building the tag feature — without context-switching to App Store Connect once.
Related guides
FAQ
Do I need a paid Apple Developer account?
Yes. A $99/year Apple Developer Program membership is required to distribute on TestFlight or submit to the App Store. You can build and run on a personal device for free with a free Apple ID, but you cannot share builds externally or submit for review without an active paid membership.
How do I submit this to the App Store?
Archive your app in Xcode (Product → Archive), then distribute via the Organizer using App Store Connect delivery. Complete your ASC listing — screenshots in all required sizes, a description, age rating, privacy nutrition labels, and your Privacy Manifest. First-time submissions typically take 1–3 business days for review.
Can I sync dreams across all of a user's devices with iCloud?
Yes. SwiftData supports CloudKit sync with minimal code changes: add the iCloud capability and a CloudKit container in Xcode's Signing & Capabilities tab, then update your ModelContainer to pass ModelConfiguration(cloudKitDatabase: .automatic). One important constraint: CloudKit requires all model properties to be optional or have default values, so audit your schema — the defaults in the model above already satisfy this requirement.
Last reviewed: 2026-05-12 by the Soarias team.