Skip to content

Your first project

Build a working SwiftUI app from zero. Scaffold, wire a real API, render the data, and run it on the simulator.

A complete walkthrough that takes you from swiftspawn new to a SwiftUI app fetching live data from an API and rendering it on screen. We’ll use the public TMDB API as the example because it’s free, real, and returns rich JSON. The same flow works for any REST API.

  • swiftspawn installed and swiftspawn doctor reports green. See Installation if not.
  • Xcode 26 with iOS 17+ simulator.
  • A TMDB account and a v4 Read Access Token (a JWT-style token, not the v3 API key). Create one here. If you’re using a different API, swap the base URL and auth header in step 4.
Terminal window
swiftspawn new MovieApp --bundle-id com.you.movieapp
cd MovieApp

new creates a real Xcode project, an SPM manifest, an infrastructure layer (networking, routing, DI, logging), and a placeholder feature so the app launches on first run.

Useful: --dry-run previews without writing, --force overwrites an existing directory.

MovieApp/
├── MovieApp.xcodeproj # real Xcode project, opens and runs
├── Package.swift # SPM manifest (Factory + auto-managed deps)
├── .swiftspawn.yml # project config the CLI uses on later runs
├── Configs/ # Debug / Staging / Release xcconfigs
└── MovieApp/
├── App/ # entry point, RootView, DIContainer
├── Infrastructure/
│ ├── Networking/ # APIClient, APIRequest, APIConfig, AppError
│ ├── Routing/ # Route enum, Router observable
│ ├── Auth/ # TokenStore (an actor)
│ └── Logging/ # Log namespace over os.Logger
├── Resources/ # Assets.xcassets
├── Features/ # one folder per feature, populated by `generate`
└── Services/ # one trio per service (protocol + impl + mock)

For a folder-by-folder breakdown, see Project layout.

2. Open it and verify the placeholder runs

Section titled “2. Open it and verify the placeholder runs”
Terminal window
swiftspawn open

Hit ⌘R. You should see a HelloView with a single button. The infrastructure works; we just haven’t given it anything to display yet.

3. Generate a feature with the CRUD recipe

Section titled “3. Generate a feature with the CRUD recipe”

Drop a sample of the JSON the API returns into the project root as sample.json:

sample.json
{
"id": 533535,
"title": "Deadpool & Wolverine",
"overview": "A listless Wade Wilson toils away in civilian life...",
"poster_path": "/8cdWjvZQUExUUTzyp4t6EDMubfO.jpg",
"backdrop_path": "/yDHYTfA3R0jFYba16jBB1ef8oIt.jpg",
"release_date": "2024-07-24",
"vote_average": 7.7,
"vote_count": 5234,
"adult": false,
"popularity": 8000.5,
"original_language": "en",
"genre_ids": [28, 35]
}

Then run:

Terminal window
swiftspawn recipe crud Movie --in Movies --from sample.json --with-tests

That single command:

  • Infers the Movie struct from the sample (snake_case → camelCase, CodingKeys, optional fields where the sample is null).
  • Writes Movie.swift under Features/Movies/Models/.
  • Creates a MovieService trio (protocol, impl with TODO stubs, mock) under Services/.
  • Creates MovieListView, MovieDetailView, MovieEditView (each with a ViewModel) under Features/Movies/.
  • Adds case movieList, movieDetail, movieEdit to Route.swift.
  • Wires those cases into RootView.swift’s navigation switch.
  • Registers movieService in DIContainer.swift.
  • Emits a ViewModel test stub per screen under Tests/MovieAppTests/.

You could add pieces individually instead:

Terminal window
swiftspawn generate screen MovieSearch on Movies --uses MovieService --with-tests
swiftspawn generate view MoviePoster in Movies
swiftspawn generate model Genre --in Movies

APIConfig ships as a struct with three instances (.development, .staging, .production); the generated AppEntry selects one at launch. We only need to change the .development instance for this walkthrough.

Open MovieApp/Infrastructure/Networking/APIConfig.swift and edit the .development block:

MovieApp/Infrastructure/Networking/APIConfig.swift
import Foundation
struct APIConfig {
let baseURL: URL
let defaultHeaders: [String: String]
static let development = APIConfig(
baseURL: URL(string: "https://api.themoviedb.org/3")!,
defaultHeaders: [
"Accept": "application/json",
// Token comes from the run-scheme env var; never commit a real token.
"Authorization": "Bearer \(ProcessInfo.processInfo.environment["TMDB_TOKEN"] ?? "")"
]
)
static let staging = APIConfig(
baseURL: URL(string: "https://api.themoviedb.org/3")!,
defaultHeaders: [:]
)
static let production = APIConfig(
baseURL: URL(string: "https://api.themoviedb.org/3")!,
defaultHeaders: [:]
)
}

In Xcode, add the env var: Product → Scheme → Edit Scheme… → Run → Arguments → Environment Variables → +, set TMDB_TOKEN to your v4 read token. The token never lands in the repo.

401 errors? You’re probably using the v3 API key (32-char hex) instead of the v4 read token (JWT-style, starts with eyJ). They look different and the API rejects v3 keys on Bearer endpoints.

The CRUD recipe gave you MovieServiceProtocol with five methods (fetch / fetchOne / create / update / delete) and a MovieServiceImpl whose fetch() body assumes a flat REST shape (/movies). TMDB wraps lists in a paged envelope and uses /movie/popular, so we’ll add a small envelope type and rewrite fetch().

Open MovieApp/Services/MovieServiceImpl.swift and replace it with:

MovieApp/Services/MovieServiceImpl.swift
import Foundation
struct TMDBPage<T: Decodable>: Decodable {
let page: Int
let results: [T]
let totalPages: Int
let totalResults: Int
enum CodingKeys: String, CodingKey {
case page, results
case totalPages = "total_pages"
case totalResults = "total_results"
}
}
final class MovieServiceImpl: MovieServiceProtocol {
private let api: APIClient
init(api: APIClient) {
self.api = api
}
func fetch() async throws -> [Movie] {
let request = APIRequest(
path: "/movie/popular",
method: .get,
query: ["language": "en-US", "page": "1"]
)
let page: TMDBPage<Movie> = try await api.send(request)
return page.results
}
// Leave the rest unimplemented until you need them.
func fetchOne(id: Int) async throws -> Movie { fatalError("not used yet") }
func create(_ resource: Movie) async throws -> Movie { fatalError("not used yet") }
func update(_ resource: Movie) async throws -> Movie { fatalError("not used yet") }
func delete(id: Int) async throws { fatalError("not used yet") }
}

Note: APIRequest itself is not generic; the generic is on api.send(_:), which infers the response type from the variable annotation (let page: TMDBPage<Movie>).

Decoding error on first run? The CLI infers types from a single sample. If the live API returns null for a field that wasn’t null in your sample, decoding will fail. Open Features/Movies/Models/Movie.swift and add ? to fields like posterPath, backdropPath, overview. Re-run.

The generated MovieListViewModel has a three-case State (loading / loaded / error(String)) with no payload on .loaded. The template comment hints // TODO: add associated data; we’ll do that now and have load() actually call the service.

Open MovieApp/Features/Movies/MovieList/MovieListViewModel.swift and replace the body with:

MovieApp/Features/Movies/MovieList/MovieListViewModel.swift
import Foundation
import Observation
import os
@MainActor
@Observable
final class MovieListViewModel {
enum State {
case loading
case loaded([Movie])
case error(String)
}
private(set) var state: State = .loading
private let movieService: MovieServiceProtocol
init(movieService: MovieServiceProtocol) {
self.movieService = movieService
}
func load() async {
state = .loading
Log.ui.debug("MovieListViewModel.load()")
do {
let movies = try await movieService.fetch()
state = .loaded(movies)
} catch {
state = .error((error as? AppError)?.userMessage ?? "Something went wrong.")
}
}
}

Now open MovieApp/Features/Movies/MovieList/MovieListView.swift. The generated view already injects the service and calls .task { await viewModel.load() }; we just need to fill in the .loaded arm:

MovieApp/Features/Movies/MovieList/MovieListView.swift
import SwiftUI
import Factory
struct MovieListView: View {
@State private var viewModel = MovieListViewModel(
movieService: Container.shared.movieService.resolve()
)
var body: some View {
content
.navigationTitle("Popular")
.task { await viewModel.load() }
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .loading:
ProgressView()
case .loaded(let movies):
List(movies, id: \.id) { movie in
HStack(spacing: 12) {
AsyncImage(url: posterURL(movie.posterPath)) { image in
image.resizable().scaledToFill()
} placeholder: {
Color.gray.opacity(0.2)
}
.frame(width: 60, height: 90)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 4) {
Text(movie.title).font(.headline)
Text(movie.overview ?? "").font(.caption).lineLimit(3)
}
}
}
case .error(let message):
VStack(spacing: 16) {
Text(message).foregroundStyle(.secondary)
Button("Try again") { Task { await viewModel.load() } }
.buttonStyle(.borderedProminent)
}
.padding(24)
}
}
private func posterURL(_ path: String?) -> URL? {
guard let path else { return nil }
return URL(string: "https://image.tmdb.org/t/p/w200\(path)")
}
}

Then make HelloView a button that pushes to the list. Open MovieApp/App/HelloView.swift:

MovieApp/App/HelloView.swift
import SwiftUI
struct HelloView: View {
@Environment(Router.self) private var router
var body: some View {
Button("Show popular movies") {
router.push(.movieList)
}
.buttonStyle(.borderedProminent)
}
}

⌘R. Tap the button. You should see a list of popular movies with posters. If something fails:

  • Blank list, no error: check the env var is set on the run scheme; the token might be empty.
  • 401: wrong token type (use v4 Read Access Token, not v3 API key).
  • Decode error: a field in the live response is null but your Movie struct says non-optional. Make it Optional and re-run.
  • No logs: requests log via os.Logger in DEBUG. Open Console.app, filter subsystem:com.you.movieapp and category:network.

You should now have a real, working app. The infrastructure (APIClient, Router, DIContainer) is doing all the plumbing; you only wrote a model, a service body, and a view body.

Run swiftspawn from anywhere inside the project. It walks up the directory tree to find .swiftspawn.yml and uses that as the project root.

Useful flags on every generator:

  • --dry-run: preview without writing files.
  • --force: overwrite existing files.
  • --quiet / --verbose: control output.
  • Add a detail screen: swiftspawn generate screen MovieDetail on Movies --uses MovieService (already done by the recipe; just fill in the body).
  • Add search: swiftspawn generate screen MovieSearch on Movies --uses MovieService.
  • Add a paginated list recipe (none ships today; compose generate screen + a service method by hand).
  • Architecture for how the layers interact.
  • Networking flow for the request/response/interceptor lifecycle.
  • Routing flow for how router.push(.movieList) actually navigates.
  • Cheat sheet for the full command list.