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.
Prerequisites
Section titled “Prerequisites”- swiftspawn installed and
swiftspawn doctorreports 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.
1. Scaffold the project
Section titled “1. Scaffold the project”swiftspawn new MovieApp --bundle-id com.you.movieappcd MovieAppnew 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.
What you got
Section titled “What you got”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”swiftspawn openHit ⌘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:
{ "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:
swiftspawn recipe crud Movie --in Movies --from sample.json --with-testsThat single command:
- Infers the
Moviestruct from the sample (snake_case → camelCase,CodingKeys, optional fields where the sample is null). - Writes
Movie.swiftunderFeatures/Movies/Models/. - Creates a
MovieServicetrio (protocol, impl with TODO stubs, mock) underServices/. - Creates
MovieListView,MovieDetailView,MovieEditView(each with a ViewModel) underFeatures/Movies/. - Adds
case movieList,movieDetail,movieEdittoRoute.swift. - Wires those cases into
RootView.swift’s navigation switch. - Registers
movieServiceinDIContainer.swift. - Emits a ViewModel test stub per screen under
Tests/MovieAppTests/.
You could add pieces individually instead:
swiftspawn generate screen MovieSearch on Movies --uses MovieService --with-testsswiftspawn generate view MoviePoster in Moviesswiftspawn generate model Genre --in Movies4. Point APIConfig at TMDB
Section titled “4. Point APIConfig at TMDB”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:
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.
5. Implement the service
Section titled “5. Implement the service”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:
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
nullfor a field that wasn’t null in your sample, decoding will fail. OpenFeatures/Movies/Models/Movie.swiftand add?to fields likeposterPath,backdropPath,overview. Re-run.
6. Render the list
Section titled “6. Render the list”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:
import Foundationimport Observationimport os
@MainActor@Observablefinal 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:
import SwiftUIimport 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:
import SwiftUI
struct HelloView: View { @Environment(Router.self) private var router
var body: some View { Button("Show popular movies") { router.push(.movieList) } .buttonStyle(.borderedProminent) }}7. Run it
Section titled “7. Run it”⌘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
nullbut yourMoviestruct says non-optional. Make itOptionaland re-run. - No logs: requests log via
os.Loggerin DEBUG. OpenConsole.app, filtersubsystem:com.you.movieappandcategory: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.
Subsequent runs
Section titled “Subsequent runs”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.
What’s next
Section titled “What’s next”- 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.