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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

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

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.