Learn Marathi

← All projects

Education / SEOActive

Learn Marathi

A static Marathi learning site that turns a real Flutter app's curriculum into indexable pages readers love and search engines trust — every word traced to source, every page above the thin-content bar, every download button honest about the app not being launched yet.

Learn Marathi homepage: searchable curriculum, cluster cards, and app call-to-action
Homepage: searchable curriculum, cluster cards, and one honest app call-to-action. Click to enlarge.
Pages
200 built
Words
1,041+ live
Units
26 → 26 pages
QA gate
prebuild, blocking

A language app with its curriculum locked inside the app is invisible to the people searching for exactly what it teaches. This project started from that gap: the Bol Marathi Flutter app holds 26 units, 156 lessons, and 1,002 trilingual words — and none of it could be found, linked, or previewed on the web. The website fixes that without becoming a content farm: a sync pipeline carries the curriculum into a static Astro site, a dataset engine turns it into pages that each serve one search intent, and a validation gate refuses to publish anything thin, duplicated, or unresolvable.

Two ways to read this page

  • Understand the system: The spark ? The struggle ? The decisions ? How it works ? Where it stands ? Reflection.
  • Build your own: Constraints ? Architecture ? The size-gate rule ? Try the pages ? The tutorial series ? Exercises at the end.

The spark

The app teaches Marathi from Hindi — greetings, food, travel, verbs, grammar, culture — in structured units a learner could browse like a course. But app-store listings don’t teach, and a marketing page with three screenshots doesn’t answer “how do you say good morning in Marathi?” So the requirement became: publish the real curriculum as real web pages, one search intent per page, with a visible path from every page into the app. Not a blog about Marathi. The curriculum itself, addressable.

The struggle

A thousand words that must never become a thousand pages. The vocabulary snapshot holds 1,002 entries. The naive move — one page per word — is exactly how sites earn thin-content penalties: hundreds of near-empty pages with one example each. The project refused. Per-word pages were rejected outright; words ship inside clusters and topic pages that clear a size gate (~15 entries) and a validator threshold (≥10 resolved words). One entry is data. Ten is a page.

Six scattered email links and a store button for an app that isn’t launched. The early site had mailto links in buttons, panels, footers, and a personal address in the source — plus store-badge instincts for an app with no listing. Every one of those is a small lie told to a future user. The fix consolidated contact into one shared waitlist form with a configured endpoint, and gated every app promise behind src/data/app.ts: comingSoon: true renders honest waitlist copy everywhere, and only setting a real store URL flips the labels. Trust is a build artifact here.

156 lessons that would drown any index. Six lesson rows across six sections in each of 26 units is 156 addressable lessons — each too thin alone, overwhelming together. They ship as 26 unit pages instead, each bundling its grammar, culture, speaking, listening, bridge, and sentence-builder rows with related words. Fewer pages, denser pages, no orphans.

The decisions

A size gate for every page

Problem
Hundreds of curriculum entries could each become a page, and most of them would be thin.
Options considered
  • One page per word/lesson
  • Curated clusters with a minimum-entry gate
  • A handful of giant index pages
Chosen
Curated clusters plus dataset pages, each clearing a size gate (~15 entries to exist) and validator minimums (≥10 words / ≥8 phrases / ≥5 bridges per published page).
Why
Search engines and learners agree on this one: a page needs enough substance to answer its intent. Shopping (12) and Colors (13) merged instead of standing alone.
Trade-offs
Some genuinely useful micro-topics wait until the curriculum grows enough entries to carry them.
Result
200 built pages (2026-10-07), zero title/description violations, no doorway pages.

Resolvers that throw, and a QA gate that blocks the build

Problem
A data-driven site can silently render wrong or empty content when a reference drifts.
Options considered
  • Trust the datasets
  • Validate at QA time only
  • Throw at build time AND gate the build
Chosen
Both: `src/lib/seo.ts` resolvers throw on any unresolvable word, phrase, or grammar reference, and `tools/validate-content.mjs` runs in `prebuild` so a failing dataset blocks the build.
Why
Two layers because they catch different failures: resolvers catch drift between datasets and snapshots, the validator catches schema, duplicate, slug-collision, script-mixing, and below-threshold content before Astro ever renders.
Trade-offs
Authors must satisfy the validator to ship — drafts stay drafts until they clear the bar.
Result
Bad references cannot ship silently; every rendered word traces to `vocab.json` or `units.json`.

Config-gated app promises

Problem
The app is not on any store yet, but the site's whole job is funneling readers toward it.
Options considered
  • Store badges linking nowhere real
  • No app mentions until launch
  • One config file gating every CTA state
Chosen
`src/data/app.ts` is the single funnel config: `comingSoon: true` renders waitlist copy, a beta URL flips primaries to “Join the beta”, and only a real store URL plus `comingSoon: false` shows download labels.
Why
One place to update on launch day with zero template edits — and zero days of promising a download that doesn't exist.
Trade-offs
The config must be remembered on launch day; the site cannot detect the store listing by itself.
Result
Every CTA on all 200 pages updates from one file; no dead store links anywhere.

A Hindi mirror without duplicating the curriculum

Problem
Half the audience thinks in Hindi first — but a second copy of the site doubles drift.
Options considered
  • No Hindi pages
  • Full duplicate Hindi site
  • Hindi chrome over the same data
Chosen
`/hi/` pages reuse the same snapshots with a Hindi UI dictionary (`src/i18n/hi.ts`), reciprocal `hreflang` both ways plus `x-default`, and the Hindi language tag on mirror pages.
Why
Only the chrome is translated; the curriculum is never forked, so a sync updates both languages at once.
Trade-offs
Non-mirrored pages fall back to machine translation via an explicit widget rather than handcrafted Hindi.
Result
4 mirrored pairs, hreflang-verified, one sync serving two languages.

Five constraints did the rest of the shaping, extracted from the implementation rather than aspired to: fully static output (no server, GitHub Pages project site under /learn-marathi); base-aware links with trailing slashes everywhere; curriculum never hand-copied (sync from the Flutter repo, website-only fixes in overrides.json and logged); no page below the thin-content bar; no app promise the config can’t back. Deployment stays npm run build plus a Pages workflow.

How it works

  • Astro 7 (static)
  • TypeScript (strict snapshots)
  • Zero-dep Node sync + QA scripts
  • MDX topic components
  • Playwright (screenshots, quizzes)
Curriculum in, pages out
flowchart LR
  subgraph APP["Flutter repo — canonical"]
      U["26 unit JSON files"]
      V["generated_vocab.dart — 1002 words"]
  end
  subgraph SYNC["npm run sync"]
      S["sync-learn-marathi.mjs"]
      O["overrides.json — logged"]
  end
  subgraph SITE["Astro site"]
      SN["units + vocab + meta.json snapshots"]
      DS["seo JSON datasets — 5 families"]
      R["seo.ts resolvers — throw on drift"]
      P["getStaticPaths → 200 pages"]
  end
  U --> S
  V --> S
  O --> S
  S --> SN
  SN --> R
  DS --> R
  R --> P

One direction, no hand edits: app data flows through validation into snapshots, datasets reference snapshots, resolvers throw on drift, pages render.

The file to understand first is tools/sync-learn-marathi.mjs: zero npm dependencies, plain Node. It reads the 26 unit files (requiring every top-level key and exactly 6 rows per section), parses the Dart vocabulary with a regex, applies the logged override layer, and writes three committed snapshots — units.json, vocab.json, meta.json — with counts in meta.json (26 units, 156 lessons, 1,002 words, synced 2026-09-20). The file to understand second is src/lib/seo.ts: typed dataset loading plus resolvers (wordsFor, speakingFor, grammarNotesFor, bridgesForPage) that throw at build time on bad references, and relatedFor, a deterministic capped internal-linking engine — declared links first, then cross-family topic matches, curriculum unit links, one same-family sibling. No random article lists anywhere.

In action

Screenshots and video captured from a live local run against the real synced curriculum (verified 2026-10-07). Every frame read before shipping; no skeletons, no placeholders.

Video walkthrough

Silent 29-second tour recorded 2026-10-07 from a live local run: homepage, food vocabulary cluster, Unit 01 lesson page, and the app landing page.

Where it stands

Verified 200 pages build with 0 errors (2026-10-07); 60/60 dataset pages published per content-manifest.json; 0 title/description violations; 83 per-page OG cards; reciprocal hreflang on 4 mirrored pairs; 3 screenshots + 29s tour video from live runs
Observed npm run sync re-runs cleanly on curriculum updates with no page-code changes; new unit JSON appears as a new /lessons/<unit>/ route automatically
Intended Native-speaker review of override glosses and respelling map; Azure TTS clips per category once reviewed
Unknown Search performance of dataset pages over time; whether the 15-entry gate holds as the curriculum doubles
Verification results
CheckResultDate
Static build200 pages, 0 errors2026-10-07
Dataset pages published60 / 60 in manifestcontent-manifest.json
Curriculum snapshot26 units · 156 lessons · 1,002 wordsmeta.json 2026-09-20
Live word count1,041+ words on homepagescreenshot 2026-10-07
Meta length audit0 violations of 60/155 limits2026-09
Per-page OG cards83 generated + fallback2026-09
Validator gateblocking in prebuildin repo

Build and manifest figures are quoted from the project REPORT and committed snapshots as point-in-time; the validator gate is structural, in the repo.

Reflection

What worked. The size gate survived contact with real content pressure — Shopping and Colors merged without drama because the number made the call, not taste. Resolvers-that-throw turned drift from a slow rot into a loud build failure, which is the only kind of content bug you actually fix.

What didn’t. The early funnel told small lies (scattered mailtos, store-badge instincts) until one config file made honesty the default. And per-word pages were tempting right up until the threshold math showed what 1,002 near-empty pages would cost.

What surprised me. How little of a 200-page SEO project is SEO: most of the work is pipeline plumbing, threshold choices, and link hygiene. The rankings follow the structure; the structure is just counting.

What I’d change. Nothing structural — the next work is evidence, not architecture: native review, measured search performance, then clips and word-pages only when their entry conditions are met.

Project timeline

  1. 2026-09
    SEO content engine lands

    Five dataset families, typed resolvers, blocking validator, manifest + report tooling — the 200-page layer without thin pages.

  2. 2026-09
    Funnel + technical SEO pass

    One shared waitlist form, config-gated CTAs, zero meta violations, 83 OG cards, reciprocal hreflang (per REPORT).

  3. 2026-09-20
    Curriculum snapshot verified

    meta.json: 26 units, 156 lessons, 1,002 words across 29 categories.

Build your own

Reproduce: clone, point BOL_MARATHI_ROOT at the app repo, run npm run sync, then npm run content:qa and npm run build. Modify: add one dataset page that clears the thresholds and watch the manifest, sitemap, and related links absorb it. Extend: mirror one more page pair into /hi/ with hreflang both ways. Re-design: replace the size gate with per-word pages for one category and feel, concretely, what thin content costs. The six-part tutorial series reconstructs this project layer by layer — start with Part 1: problem and architecture.

Tutorial series

learn-marathi

  1. Part 1: The problem: 200 Marathi lessons that must each earn their place
  2. Part 2: One intent per page: what became a page and what didn't
  3. Part 3: The dataset engine: validated content that cannot render lies
  4. Part 4: Pages and funnel: a tour from hub to app without dark patterns
  5. Part 5: Scale without duplication: linking, languages, and images at 200 pages
  6. Part 6: Capstone: rebuild one slice of the 200-page engine yourself