How to Build a Crossword App in SwiftUI

A crossword app lets users solve interactive word puzzles from a curated library, with clue navigation, letter-by-letter input, and persistent progress. It's the right project for indie developers targeting a daily-habit audience with strong subscription retention.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Advanced Estimated time: 2–4 weeks Updated: May 12, 2026

Prerequisites

Architecture overview

SwiftData is the persistence layer, storing Puzzle models that hold grid layouts, clue maps, and per-session user answers. The view layer splits between LibraryView (puzzle browser driven by @Query) and CrosswordGridView (the interactive solving surface). An @Observable PuzzleViewModel owns selection and keyboard-input state. PDFKit handles optional PDF puzzle import for power users. StoreKit 2 gates premium puzzles behind a subscription checked on appear.

CrosswordApp/
├── Models/
│   ├── Puzzle.swift            # @Model: grid, clues, answers
│   └── GridCoord.swift         # (row, col) coordinate value type
├── Views/
│   ├── LibraryView.swift       # puzzle browser, @Query + filter
│   ├── CrosswordGridView.swift # interactive grid surface
│   └── ClueListView.swift      # across / down clue panels
├── ViewModels/
│   └── PuzzleViewModel.swift   # @Observable solving state
└── Services/
    └── StoreService.swift      # StoreKit 2 subscription

Step-by-step

1. Data model

Define the Puzzle SwiftData model to store grid layout, clues, and the user's in-progress answer array so every session resumes exactly where it left off.

import SwiftData
import Foundation

enum PuzzleDifficulty: String, Codable { case easy, medium, hard, expert }

@Model
final class Puzzle {
    var id: UUID = UUID()
    var title: String
    var author: String
    var difficulty: PuzzleDifficulty
    var gridSize: Int                    // e.g. 15 for a 15×15 grid
    var grid: [String]                   // "#" = black cell; letter = solution
    var cluesAcross: [String: String]    // "1A": "Clue text"
    var cluesDown: [String: String]      // "1D": "Clue text"
    var userAnswers: [String]            // player entries; "" = blank
    var isCompleted: Bool = false
    var isPremium: Bool = false
    var addedDate: Date = Date.now

    init(title: String, author: String, difficulty: PuzzleDifficulty,
         gridSize: Int, grid: [String],
         cluesAcross: [String: String], cluesDown: [String: String]) {
        self.title = title; self.author = author
        self.difficulty = difficulty; self.gridSize = gridSize
        self.grid = grid
        self.cluesAcross = cluesAcross; self.cluesDown = cluesDown
        self.userAnswers = Array(repeating: "", count: gridSize * gridSize)
    }
}

2. Core UI — interactive crossword grid

Render cells with nested ForEach; track the selected index and current direction in PuzzleViewModel, toggling direction on re-tap of the same cell.

struct CrosswordGridView: View {
    @Bindable var vm: PuzzleViewModel
    private let cellSize: CGFloat = 36

    var body: some View {
        ScrollView([.horizontal, .vertical]) {
            VStack(spacing: 1) {
                ForEach(0..

3. Puzzle library

Use @Query for live SwiftData updates; gate premium puzzles with a StoreKit subscription check loaded once via .task so the entitlement is always fresh.

struct LibraryView: View {
    @Environment(\.modelContext) private var ctx
    @Query(sort: \Puzzle.addedDate, order: .reverse) private var puzzles: [Puzzle]
    @State private var filter: PuzzleDifficulty? = nil
    @State private var isSubscribed = false

    var visible: [Puzzle] {
        filter.map { d in puzzles.filter { $0.difficulty == d } } ?? puzzles
    }

    var body: some View {
        NavigationStack {
            List(visible) { puzzle in
                NavigationLink(value: puzzle) {
                    LibraryRowView(puzzle: puzzle,
                                   locked: puzzle.isPremium && !isSubscribed)
                }
            }
            .navigationTitle("Puzzle Library")
            .navigationDestination(for: Puzzle.self) { puzzle in
                if puzzle.isPremium && !isSubscribed {
                    SubscribePromptView()
                } else {
                    PuzzleSolverView(puzzle: puzzle)
                }
            }
            .toolbar { DifficultyFilterMenu(selection: $filter) }
        }
        .task { isSubscribed = await StoreService.shared.hasActiveSubscription() }
    }
}

4. Privacy Manifest (PrivacyInfo.xcprivacy)

Add a PrivacyInfo.xcprivacy file to your app target — required since iOS 17.4 — declaring every required-reason API your app accesses, or App Store submission will be rejected.

<?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>

Common pitfalls

  • Using non-string dictionary keys in SwiftData: [Int: String] clue maps won't serialize reliably in SwiftData on iOS 17. Use string keys like "1A" and "3D", or model clues as a separate @Model with a relationship.
  • iPad hardware keyboard focus loss: When your app enters split-screen on iPad, the window resizes and your hidden UITextField can lose first-responder status. Re-claim focus in .onChange(of: scenePhase) when returning to .active.
  • PDFKit scale on small screens: PDFView defaults to .scaleFactor = 1.0, which renders imported PDF puzzles unreadably small on 4.7" iPhones. Set scaleFactor from GeometryReader bounds, not a hardcoded constant.
  • App Store rejection — thin puzzle library: Reviewers have rejected crossword apps with fewer than 15 playable puzzles at launch. Seed your SwiftData store with at least 20 complete, error-free puzzles before you submit.
  • Subscription entitlement after reinstall: Always iterate Transaction.currentEntitlements in a .task on app launch — not just on purchase. Users who delete and reinstall won't see their subscription restored if you only check the purchase callback.

Adding monetization: Subscription

Use StoreKit 2's Product.products(for:) to load monthly and annual subscription SKUs defined in App Store Connect, then present them in a paywall sheet. Gate premium puzzles by checking Transaction.currentEntitlements — an async sequence that refreshes automatically on renewal, cancellation, and Family Sharing changes. Centralise this in an @Observable StoreService singleton so any view can react without re-fetching. For crossword apps, an annual plan at $19.99–$24.99 with a 7-day free trial tends to convert best; lead with the puzzle volume ("300+ puzzles, new ones weekly") rather than feature names in your paywall copy.

Shipping this faster with Soarias

Soarias automates the scaffolding that devours time on an advanced project like this: it generates the SwiftData schema from a natural-language prompt, wires up the ModelContainer, drops in a pre-built StoreKit 2 subscription manager with entitlement checking, and generates the correct PrivacyInfo.xcprivacy entries for the APIs you use. Fastlane lanes for screenshot capture across all required device sizes and App Store Connect metadata upload are configured on day one — no lane files to write from scratch.

For an advanced app at this complexity level, that realistically saves 3–5 days of boilerplate and submission configuration. The interactive grid and puzzle-import logic still need your full attention, but the entire project infrastructure, signing, and submission pipeline are handled before you write a single line of game logic.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. The free tier lets you sideload onto your own device via Xcode, but TestFlight distribution, App Store submission, and StoreKit 2 sandbox testing with real subscription products all require an active Apple Developer Program membership ($99/year).

How do I submit this to the App Store?

Archive your build in Xcode (Product → Archive), then upload via Organizer or xcrun altool. In App Store Connect, complete your metadata, upload screenshots for every required device size, configure your subscription products under Monetization, and attach them to your version before clicking Submit for Review. App Store review typically takes 1–3 business days for a first submission.

How do I create or import crossword puzzle data?

The standard open format is .puz (Across Lite). Parse it with an open-source Swift .puz library from GitHub, or export JSON from a puzzle-authoring tool like Crossword Compiler or Crossfire. Write a one-time import script that seeds your SwiftData store via modelContext.insert() at first launch, bundling initial puzzles inside your app target. For subscriber-exclusive content, serve new puzzles over a lightweight JSON endpoint and sync them into SwiftData on each app open.

Last reviewed: 2026-05-12 by the Soarias team.