Manga Kotoba, Part 1: From Zero to First iOS Screen

📚 Manga Kotoba — Building an iOS vocabulary app
Part 1 From Zero to Screen
Part 2 Vocab Scraping + AI
Part 3 Progress Tracking
Part 4 Paywall + Auth
Part 5 App Store Launch

The Spark

“The hardest part of a side project isn’t the code — it’s having a problem you genuinely want to solve.”

I’ve been learning Japanese for a while now. Not casually — I mean the full commitment: Anki decks at breakfast, grammar guides on lunch breaks, anime with Japanese subtitles in the evening. And somewhere in all that, I fell in love with manga. Not just as entertainment, but as a learning medium. Real dialogue, furigana on hard words, visual context that makes meaning stick.

But there was a gap — a frustrating one. My vocabulary apps had no idea which manga I was reading. Anki didn’t know about One Piece. WaniKani couldn’t tell me which kanji appear on page 12 of a specific volume. Everything was disconnected. I’d encounter 湿気 (しっけ — humidity) in a beach chapter, look it up, and it’d vanish into a generic deck with no connection to the page where I found it.

That’s where Manga Kotoba was born. An iOS app backed by a real API that maps vocabulary to the page it appears on. Browse your manga library, open any page, see exactly which words you don’t know yet. Mark them as known. Track your progress. Build vocabulary in context.

This is Part 1 of a five-part series about building it from scratch.


Choosing the Stack — Interactive Decision Tree

Choosing a stack for a side project is surprisingly emotional. You want to learn something new, but you also want to ship something. Every decision is a trade-off between exploration and pragmatism. Here’s the exact thought process I went through — click each node to see my reasoning.

🗺 Tech Stack Decision Tree

What kind of app?
The vocabulary is tied to physical pages of manga. Users need to browse a library on their phone, flip through volumes, and mark words inline. That calls for a native mobile experience — not a web app.
Mobile app → iOS only. I have a Mac and an iPhone. Android would mean a whole separate toolchain. iOS means Xcode, and Xcode means either UIKit or SwiftUI. SwiftUI is the modern path — declarative, compositional, and honestly a pleasure once it clicks. Decision: SwiftUI.
Do you need a backend?
The vocabulary data per page has to live somewhere. So does user auth, known-word tracking, and the manga library. All of that needs to be shared across devices and persisted server-side.
Yes, definitely. A "local only" approach would mean bundling all the vocabulary for every manga into the app binary — infeasible at scale. The backend holds the vocabulary database, handles auth, and exposes a REST API. I know PHP well, I've shipped real apps with Symfony. Symfony 7 it is.
Which database and API format?
The data model is relational — manga has volumes, volumes have pages, pages have words, words are vocabulary entries. MySQL is a natural fit. As for the API layer...
API Platform. It's the Symfony ecosystem's superpower. Add #[ApiResource] to an entity and you get GET/POST/PATCH/DELETE endpoints, pagination, filtering, serialisation groups, and OpenAPI docs — for free. I'd used it on client work before. The database: MySQL 8 (familiar, reliable, excellent with Doctrine ORM).
Where to deploy?
I need the backend accessible from the iOS simulator and eventually a real device. Self-hosting is an option. But for a side project in 2026...
Heroku. Push to deploy, managed Postgres (though I swapped to MySQL addon), automatic HTTPS, no nginx config hell. For a learning project, the operational overhead of a VPS isn't worth it. There will be pain later (there's always pain with Heroku and HTTPS — spoiler: Part 1 ends with a commit called "General fix, a lot of issues due to heroku usage").

Day 1: Raw Symfony — The Domain Model

The very first backend commit after the raw Symfony project was b153737: feat(db): add initial schema migration for manga platform.

Before a single endpoint, before a single API call from the phone, I sat down and drew out the domain. What are the things in this system?

// The core entities, sketched on paper first

Manga       → has many Volumes
Volume      → belongs to Manga, has many Pages
Page        → belongs to Volume, has many Words (occurrences)
Vocabulary  → the central dictionary (word, reading, meaning, JLPT)
Word        → a page-level occurrence linking PageVocabulary
KnownWord   → User ↔ Vocabulary join (words the user has learned)
UserLibrary → User ↔ Manga join (the user's personal collection)

That first migration created all ten tables in one go. Here’s the heart of it — the manga and vocabulary tables that everything else hangs off:

// migrations/Version20260228145912.php

$this->addSql('CREATE TABLE manga (
    id          INT AUTO_INCREMENT NOT NULL,
    title       VARCHAR(255) NOT NULL,
    description LONGTEXT DEFAULT NULL,
    cover_url   VARCHAR(500) DEFAULT NULL,
    author      VARCHAR(255) DEFAULT NULL,
    status      VARCHAR(10) DEFAULT NULL,
    total_pages INT NOT NULL DEFAULT 0,
    PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');

$this->addSql('CREATE TABLE vocabulary (
    id             INT AUTO_INCREMENT NOT NULL,
    text           VARCHAR(255) NOT NULL,
    reading        VARCHAR(255) DEFAULT NULL,
    meaning        LONGTEXT DEFAULT NULL,
    jlpt_level     VARCHAR(2) DEFAULT NULL,
    part_of_speech VARCHAR(50) DEFAULT NULL,
    PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 ENGINE = InnoDB');

Interactive Entity-Relationship Diagram

🗄 Domain Model — Entity Relationships

Manga id: INT PK title: VARCHAR author: VARCHAR status: VARCHAR total_pages: INT Volume id: INT PK manga_id: INT FK number: INT title: VARCHAR Page id: INT PK volume_id: INT FK number: INT image_url: VARCHAR Vocabulary id: INT PK text: VARCHAR reading: VARCHAR meaning: TEXT jlpt_level: VARCHAR Word page_id: INT FK vocabulary_id: INT FK position: INT KnownWord user_id: INT FK vocabulary_id: INT FK UNIQUE constraint UserLibrary user_id: INT FK manga_id: INT FK status: VARCHAR 1:N 1:N 1:N N:1 Core relation User feature join

The design separates content (Manga/Volume/Page/Vocabulary) from user state (KnownWord/UserLibrary). That separation would prove its worth later when adding progress tracking and subscriptions — the core content never needs to know about any specific user.


The API Platform Magic Moment

After the migration came the entities. And after the entities came the moment that made me laugh out loud in my apartment at 11 PM.

I added #[ApiResource] to Manga.php:

// src/Entity/Manga.php

#[ORM\Entity(repositoryClass: MangaRepository::class)]
#[ApiResource(
    operations: [
        new GetCollection(
            normalizationContext: ['groups' => ['manga:read']],
            security: "is_granted('PUBLIC_ACCESS')",
        ),
        new Get(
            normalizationContext: ['groups' => ['manga:read', 'manga:item']],
            security: "is_granted('PUBLIC_ACCESS')",
        ),
        new Post(security: "is_granted('ROLE_MANAGER')"),
        new Patch(security: "is_granted('ROLE_MANAGER')"),
        new Delete(security: "is_granted('ROLE_MANAGER')"),
    ],
)]
class Manga
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    #[Groups(['manga:read'])]
    private ?int $id = null;
    // ... fields ...
}

That’s it. One PHP attribute. Now GET /api/mangas returns paginated JSON. GET /api/mangas/1 returns a single manga. POST /api/mangas (if you’re ROLE_MANAGER) creates one. OpenAPI docs appear at /api/docs. Filters, sorting, pagination — all configurable from the same attribute.

API Platform lesson: The serialization groups (manga:read, manga:write) are how you control exactly which fields appear in responses vs. mutations. Get these right early — retrofitting them is painful when the iOS app already expects a specific shape.

The First iOS Screen — Browser Mockup

The iOS side started life as a pure mockup. The commit 97349b3 Browser screen mockup had zero networking — just hard-coded data and a LazyVGrid. The goal was to feel the UI before wiring up the API. Here’s what the browse screen looked like conceptually. Click a card to see the detail view:

9:41 ●●●

Browse

Library
Browse
Profile

The LazyVGrid with .adaptive(minimum: 150) columns was the first piece of SwiftUI that genuinely delighted me. Write it once and it adapts from a 3-column iPad layout down to 2-column on a small iPhone — no breakpoints, no media queries, just adaptive layout from a single declaration.

// The browse grid — dead simple
struct BrowseSeriesView: View {
    @State private var searchText = ""
    let columns = [GridItem(.adaptive(minimum: 150), spacing: 16)]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: columns, spacing: 16) {
                    ForEach(mangas) { manga in
                        NavigationLink(
                            destination: MangaDetailView(slug: manga.slug)
                        ) {
                            MangaCardView(manga: manga)
                        }
                    }
                }
                .padding()
            }
            .navigationTitle("Browse")
        }
    }
}

The First SwiftUI Struggles

The mockup was easy. Wiring it to a real API was where the reality checks started coming in thick and fast.

Problem 1 — localhost doesn’t mean the same thing inside the Simulator

Commit 7b21662: “Make it work with the docker localhost”. Three hours of debugging to understand something that seems obvious in hindsight: the iOS Simulator runs in a separate network namespace. When you type http://localhost:8080 in Swift, you’re asking the iPhone simulator to connect to port 8080 on its localhost — which is empty.

The fix: use your Mac’s local IP address instead.

// ❌ Before — connects to the simulator's own loopback
enum APIConfig {
    static let baseURL = URL(string: "http://localhost:8080")!
}

// ✅ After — connects to the Mac running the Docker container
enum APIConfig {
    static let baseURL = URL(string: "http://192.168.1.42:8080")!
}
⚠️ iOS Simulator networking: The simulator shares your Mac's Wi-Fi connection but has its own loopback (127.0.0.1). Use ifconfig | grep "inet " | grep -v 127 to find your Mac's local IP. Better yet, add an #if DEBUG block that swaps between your local IP and the production Heroku URL.

Problem 2 — JSON-LD @id / @type fields that Codable doesn’t expect

API Platform returns JSON-LD by default. That means every response includes @id, @type, and hydra:* fields that Swift’s Codable has never heard of. My first MangaModels.swift had a clean, simple struct:

// What I wanted
struct APIMangaItem: Codable {
    let id:    Int
    let title: String
}

// What the API actually returned
// {
//   "@id": "/api/mangas/1",
//   "@type": "Manga",
//   "id": 1,
//   "title": "Naruto"
// }
// → Swift crashes: "No value associated with key @id"

The solution was either switching API Platform to output plain JSON (add format: 'json' to the GetCollection operation), or adding CodingKeys to ignore unknown fields. I went with the first option — cleaner responses, no special-casing in the client:

new GetCollection(
    formats: ['json' => ['application/json']],
    normalizationContext: ['groups' => ['manga:read']],
),

Problem 3 — Heroku, HTTPS, and App Transport Security

Commit c982321: “General fix, a lot of issues due to heroku usage”. This one was a cluster of smaller problems all arriving at once the first time I deployed to Heroku and pointed the app at the real URL.

iOS enforces App Transport Security (ATS) by default — all requests must be HTTPS. Heroku gives you HTTPS for free on .herokuapp.com domains, but the backend was briefly returning HTTP redirects, and the Symfony TRUSTED_PROXIES setting wasn’t configured for Heroku’s load balancer. The result: infinite redirect loops or blank responses.

Heroku + Symfony tip: Set TRUSTED_PROXIES=REMOTE_ADDR in your Heroku config vars and add APP_URL=https://your-app.herokuapp.com. Symfony needs to know it's behind a proxy so it generates correct HTTPS URLs for API responses.

Also: CORS. The iOS simulator sends requests with an Origin header. Without nelmio/cors-bundle configured correctly, every preflight returned 403. The fix was three lines of YAML, but finding those three lines cost me an afternoon.


Marking Words as Known — Swipe Demo

Commit b45d99a: “Make it possible to mark a word as known”. This was the feature that made the app feel real for the first time. Not just a list of words — a thing you could act on.

The interaction model is obvious once you’ve used any flashcard app: swipe right to mark as known, swipe left to skip. The iOS implementation uses a DragGesture on the word row that translates to a CGAffineTransform and fires an API call on release if the offset is past a threshold.

Try it here — drag the card or use the buttons:

next word
✓ Known ✗ Skip 湿気 しっけ humidity ← drag or use buttons →
0 known · 0 skipped
🎉 Session complete!

The SwiftUI implementation behind this is surprisingly tidy. A @GestureState property tracks the drag offset and drives both the visual transform and the threshold detection:

struct VocabWordRowView: View {
    let word: VocabWord
    var onMarkKnown: () -> Void

    @GestureState private var dragOffset: CGFloat = 0
    @State        private var finalOffset: CGFloat = 0
    private let   threshold: CGFloat = 100

    var body: some View {
        let total = finalOffset + dragOffset
        return HStack {
            WordContent(word: word)
        }
        .offset(x: total)
        .opacity(1.0 - abs(total) / 200)
        .gesture(
            DragGesture()
                .updating($dragOffset) { value, state, _ in
                    state = value.translation.width
                }
                .onEnded { value in
                    if value.translation.width > threshold {
                        onMarkKnown()
                    } else {
                        finalOffset = 0
                    }
                }
        )
    }
}
SwiftUI gesture lesson: Using @GestureState instead of @State for drag offset means the value automatically resets to zero when the gesture ends — no need to manually reset it in onEnded. This eliminates an entire class of jitter bugs (see the much later commit: "fix: eliminate swipe gesture jitter using @GestureState").

Milestone: Real Data in a Real App

After all the wrangling — the localhost confusion, the JSON-LD chaos, the Heroku HTTPS debugging — there was a moment that made everything feel worth it.

“The first time your app loads real data from your own API is genuinely one of the best feelings in software development.”

I had seeded a handful of manga into the database. I had the Symfony server running on Heroku. I opened Xcode, hit the Run button, and watched the iOS Simulator boot. The BrowseStore kicked off its URLSession request. ProgressView("Loading…") appeared on screen.

And then — manga cards. Real ones. With real titles from the database I’d designed, populated through an API I’d built, decoded by a Swift struct I’d written.

The data model I’d sketched on paper was now pixels on a screen that lived in my pocket.

That feeling — small but undeniable — is why side projects exist.


What’s Next

Part 1 ends here: the stack chosen, the domain modelled, the first screen wired to a real API. But there’s a huge gap in the story: where does the actual vocabulary data come from? I haven’t explained how 10,000+ words end up associated with specific manga pages.

That’s what Part 2 is about.

Part 1 — you are here
From Zero to First iOS Screen
Stack decisions, Symfony + API Platform setup, SwiftUI browser mockup, first networking struggles.
✓ Published
Part 2 — coming soon
Scraping, AI & the CSV Pipeline
Building a vocabulary extraction engine with Gemini AI, page-by-page analysis, and the CSV import pipeline that populates the database.
Swift · PHP · AI
Part 3 — coming soon
Progress Tracking
Read progress per volume, vocabulary density badges, the known-word ring, and making "how far through this manga am I?" a first-class feature.
SwiftUI · UX
Part 4 — coming soon
Paywall, Auth & Subscriptions
Passkey login, StoreKit 2 subscription paywall, gating premium content, and the awkward dance of testing in-app purchases in Xcode.
StoreKit · Security
Part 5 — coming soon
App Store Launch
Screenshots, metadata, App Review rejections, privacy policy, and what it actually feels like to ship something to the App Store for the first time.
Launch · Reflection

If you’re building something similar — an iOS app on top of a Symfony API, or just learning Japanese through something you actually care about — I’d love to hear about it. The project is private for now but Part 2 will include real code excerpts from the AI pipeline.

じゃあ、またね 👋