How to Build a Journaling App in SwiftUI
A journaling app lets users capture daily thoughts and feelings, then explore mood patterns over time with interactive Charts. It is the ideal first SwiftUI project because it covers TextEditor, SwiftData persistence, and data visualization without requiring any external APIs or accounts.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge — you should be comfortable with Views, State, and simple layouts
- SwiftData requires iOS 17, so use the iOS 17+ simulator or a real device for all testing
Architecture overview
The app is built on a single SwiftData model — JournalEntry — persisted in a local SQLite store. A @Query property in the list view keeps the UI automatically in sync with the database. The root is a TabView with two tabs: the writing side (EntryListView) and the analytics side (MoodChartView). Because everything is local, there is no networking layer, and the Privacy Manifest is minimal.
JournalApp/ ├── JournalAppApp.swift ← @main + .modelContainer ├── Models/ │ ├── JournalEntry.swift ← @Model (SwiftData) │ └── Mood.swift ← enum with emoji helpers ├── Views/ │ ├── ContentView.swift ← TabView root │ ├── EntryListView.swift ← @Query list + delete │ ├── EntryRow.swift ← list cell │ ├── EntryDetailView.swift ← read-only detail │ ├── NewEntryView.swift ← TextEditor + mood picker │ └── MoodChartView.swift ← Charts bar chart ├── PrivacyInfo.xcprivacy ← App Store required └── Assets.xcassets
Step-by-step
1. Project setup
In Xcode 16 choose File → New → Project → App. Name it JournalApp, set the interface to SwiftUI, and tick Use SwiftData. Open the generated JournalAppApp.swift and attach a model container — this one modifier provisions the entire SQLite-backed persistence stack. Add a simple ContentView that hosts a TabView so you can build each tab independently.
// JournalAppApp.swift
import SwiftUI
import SwiftData
@main
struct JournalAppApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: JournalEntry.self)
}
}
// ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
TabView {
EntryListView()
.tabItem { Label("Journal", systemImage: "book.closed") }
MoodChartView()
.tabItem { Label("Insights", systemImage: "chart.bar") }
}
}
}
#Preview {
ContentView()
.modelContainer(for: JournalEntry.self, inMemory: true)
}
2. Data model
Create two files in a Models group. The Mood enum is Codable so SwiftData can store it as a raw string. Marking JournalEntry with @Model is the only schema declaration you need — no migration configs or managed object subclasses.
// Models/Mood.swift
import Foundation
enum Mood: String, Codable, CaseIterable {
case happy, calm, neutral, anxious, sad, frustrated
var label: String { rawValue.capitalized }
var emoji: String {
switch self {
case .happy: return "😊"
case .calm: return "😌"
case .neutral: return "😐"
case .anxious: return "😰"
case .sad: return "😢"
case .frustrated: return "😤"
}
}
}
// Models/JournalEntry.swift
import SwiftData
import Foundation
@Model
final class JournalEntry {
var date: Date
var title: String
var entryBody: String
var mood: Mood
init(
date: Date = .now,
title: String = "",
entryBody: String = "",
mood: Mood = .neutral
) {
self.date = date
self.title = title
self.entryBody = entryBody
self.mood = mood
}
}
3. Entry list view
@Query fetches all entries sorted by date newest-first and re-renders the list whenever the store changes. Use navigationDestination(for:) rather than the deprecated NavigationLink(destination:isActive:) — it separates routing from layout and works correctly with sheets.
// Views/EntryListView.swift
import SwiftUI
import SwiftData
struct EntryListView: View {
@Query(sort: \JournalEntry.date, order: .reverse)
private var entries: [JournalEntry]
@Environment(\.modelContext) private var modelContext
@State private var showingNewEntry = false
var body: some View {
NavigationStack {
Group {
if entries.isEmpty {
ContentUnavailableView(
"No entries yet",
systemImage: "book.closed",
description: Text("Tap + to write your first entry.")
)
} else {
List {
ForEach(entries) { entry in
NavigationLink(value: entry) {
EntryRow(entry: entry)
}
}
.onDelete(perform: deleteEntries)
}
}
}
.navigationTitle("Journal")
.navigationDestination(for: JournalEntry.self) { entry in
EntryDetailView(entry: entry)
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Entry", systemImage: "square.and.pencil") {
showingNewEntry = true
}
}
ToolbarItem(placement: .navigationBarLeading) {
EditButton()
}
}
.sheet(isPresented: $showingNewEntry) {
NewEntryView()
}
}
}
private func deleteEntries(at offsets: IndexSet) {
for index in offsets { modelContext.delete(entries[index]) }
}
}
struct EntryRow: View {
let entry: JournalEntry
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(entry.title.isEmpty ? "Untitled" : entry.title)
.font(.headline)
Spacer()
Text(entry.mood.emoji).font(.title3)
}
Text(entry.date, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
if !entry.entryBody.isEmpty {
Text(entry.entryBody)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
}
struct EntryDetailView: View {
let entry: JournalEntry
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text(entry.date, style: .date).foregroundStyle(.secondary)
Spacer()
Text(entry.mood.emoji + " " + entry.mood.label)
.foregroundStyle(.secondary)
}
.font(.subheadline)
Text(entry.entryBody)
.font(.body)
}
.padding()
}
.navigationTitle(entry.title.isEmpty ? "Entry" : entry.title)
.navigationBarTitleDisplayMode(.large)
}
}
#Preview {
EntryListView()
.modelContainer(for: JournalEntry.self, inMemory: true)
}
4. New entry editor with mood picker
This is the core of the app. A full-height TextEditor gives users room to write, while a LazyVGrid renders the six mood options as tappable emoji tiles. The selected tile gets an accent-colored border so the choice is always obvious. On save, a new JournalEntry is inserted into the model context and SwiftData flushes it to disk automatically.
// Views/NewEntryView.swift
import SwiftUI
import SwiftData
struct NewEntryView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var title = ""
@State private var entryBody = ""
@State private var mood: Mood = .neutral
@State private var date = Date.now
private let columns = Array(repeating: GridItem(.flexible()), count: 3)
var isSaveDisabled: Bool { title.isEmpty && entryBody.isEmpty }
var body: some View {
NavigationStack {
Form {
Section("Entry") {
TextField("Title", text: $title)
TextEditor(text: $entryBody)
.frame(minHeight: 160)
}
Section("How are you feeling?") {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(Mood.allCases, id: \.self) { m in
Button {
mood = m
} label: {
VStack(spacing: 4) {
Text(m.emoji).font(.largeTitle)
Text(m.label).font(.caption2)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
mood == m
? Color.accentColor.opacity(0.18)
: Color.secondary.opacity(0.07)
)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(
mood == m ? Color.accentColor : Color.clear,
lineWidth: 2
)
)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 6)
}
Section {
DatePicker("Date", selection: $date, displayedComponents: .date)
}
}
.navigationTitle("New Entry")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { saveEntry() }
.disabled(isSaveDisabled)
}
}
}
}
private func saveEntry() {
let entry = JournalEntry(
date: date,
title: title,
entryBody: entryBody,
mood: mood
)
modelContext.insert(entry)
dismiss()
}
}
#Preview {
NewEntryView()
.modelContainer(for: JournalEntry.self, inMemory: true)
}
5. Mood trends chart & Privacy Manifest
Import the Charts framework (no extra package required — it ships with Xcode) to show a bar chart of mood frequency. Then add PrivacyInfo.xcprivacy to your app target via File → New → File → App Privacy. Apple will reject your binary at upload time if this file is absent, so it is always the final step before submitting.
// Views/MoodChartView.swift
import SwiftUI
import SwiftData
import Charts
struct MoodChartView: View {
@Query(sort: \JournalEntry.date, order: .reverse)
private var entries: [JournalEntry]
private var moodCounts: [(mood: Mood, count: Int)] {
let grouped = Dictionary(grouping: entries, by: \.mood)
return Mood.allCases.map { mood in
(mood: mood, count: grouped[mood]?.count ?? 0)
}
}
private var topMood: (mood: Mood, count: Int)? {
moodCounts.filter { $0.count > 0 }.max { $0.count < $1.count }
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
Chart(moodCounts, id: \.mood) { item in
BarMark(
x: .value("Mood", item.mood.label),
y: .value("Entries", item.count)
)
.foregroundStyle(by: .value("Mood", item.mood.label))
.annotation(position: .top) {
Text(item.mood.emoji).font(.caption)
}
}
.chartLegend(.hidden)
.frame(height: 220)
.padding(.horizontal)
VStack(alignment: .leading, spacing: 6) {
Text("Total entries: \(entries.count)")
.font(.headline)
if let top = topMood {
Text("Most common: \(top.mood.emoji) \(top.mood.label)")
.foregroundStyle(.secondary)
.font(.subheadline)
}
}
.padding(.horizontal)
}
.padding(.vertical)
}
.navigationTitle("Mood Insights")
}
}
}
#Preview {
MoodChartView()
.modelContainer(for: JournalEntry.self, inMemory: true)
}
Add PrivacyInfo.xcprivacy to your app 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>
<!-- No tracking, no data sent off-device -->
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<!-- Required if you use UserDefaults anywhere (including SwiftUI internals) -->
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
- Naming a property
bodyin a SwiftData model. Swift will compile it, but it silently shadows SwiftUI'sView.bodyin previews and causes confusing build errors. Name journal text fieldsentryBodyorcontentinstead. - Missing Privacy Manifest at upload time. Xcode will not warn you locally — the rejection arrives from App Store Connect after you upload. Always add
PrivacyInfo.xcprivacybefore your first TestFlight build, not as an afterthought. - TextEditor has no placeholder. Unlike TextField, TextEditor does not support a
promptorplaceholderparameter. Overlay aTextview manually and hide it when the string is non-empty. - SwiftData migration on first schema change. Adding a new property to
JournalEntryafter shipping will crash on launch unless you either supply a default value in the initializer or write aSchemaMigrationPlan. Plan your model schema carefully before v1.0. - App Store review: journal apps need a functional demo account or sample data. Reviewers cannot write entries themselves to test in-app purchases. Seed sample entries in a
DEBUGbuild or document the flow clearly in your review notes.
Adding monetization: Subscription
Use StoreKit 2's Product.products(for:) to fetch auto-renewable subscription products you configure in App Store Connect. Gate premium features — such as mood trend history beyond 7 days, export to PDF, or custom themes — behind a @AppStorage-backed entitlement flag that you set after verifying the transaction with Transaction.currentEntitlement(for:). A single SubscriptionStore view (available from iOS 17) renders a native paywall with no custom layout required, which significantly reduces App Store review friction. Always call AppStore.sync() on launch to restore purchases for users reinstalling the app.
Shipping this faster with Soarias
Soarias automates the setup work that consumes the first half of a beginner project: it scaffolds the SwiftData model container, generates the correct PrivacyInfo.xcprivacy for your accessed APIs, configures fastlane with match for code signing, writes the App Store Connect metadata (screenshots, description, keywords), and submits the build — all from a single prompt in Claude Code.
For a beginner-complexity app like this one, the manual path typically takes a full weekend just to get a signed build onto TestFlight. With Soarias handling scaffolding, signing, and submission in one pass, most developers have a live TestFlight link within a few hours of starting, leaving the weekend free for the actual writing and mood-picker UX that makes the app worth using.
Related guides
FAQ
Does this work on iOS 16?
No. SwiftData requires iOS 17. If you need iOS 16 support, replace @Model with a Core Data NSManagedObject subclass and use @FetchRequest instead of @Query. The Charts framework also requires iOS 16, so that part is safe — only the persistence layer needs changing.
Do I need a paid Apple Developer account to test?
You can run the app on a real device with a free Apple ID via Xcode's personal team, but you are limited to 3 apps at a time and they expire after 7 days. For TestFlight distribution, in-app purchases, and App Store submission you need the $99/year Apple Developer Program. The simulator works fully with a free account and is fine for all development work.
How do I add this to the App Store?
Create an app record in App Store Connect, configure at least one screenshot per required device size (Xcode's simulator can generate these), fill in the privacy nutrition labels, and upload your archive via Product → Archive → Distribute App. After processing, submit the build for review. First reviews typically take 24–48 hours for a simple utility.
I changed my SwiftData model after testing — now the app crashes on launch. What happened?
SwiftData performs a lightweight migration automatically when you add properties that have default values in the initializer. If you add a non-optional property without a default, or rename a property, the existing store schema no longer matches and SwiftData throws a fatal error. During development, delete the app from the simulator to wipe the store. For shipped versions, write a SchemaMigrationPlan with a MigrationStage.lightweight or .custom stage before releasing the update.
Last reviewed: 2026-05-12 by the Soarias team.