Skip to main content
  1. Insights/

Insights · Deep Dive

Kahf Kids SEO Architecture: Meta Injection + Sitemaps

··12 mins·
Mjashem
Deep-Dive Seo Sitemap Meta-Injection Json-Ld
Mohammad Jashem
Author
Mohammad Jashem
Senior full-stack mobile engineer with 7+ years building production apps end-to-end for Android, iOS, Web, and TV. Flutter, React Native, Expo — plus backend, CI/CD, and infrastructure. Architecture-first, AI-native delivery. Available for freelance and Upwork engagements.
Table of Contents

I’m Mohammad Jashem — a Senior Mobile Architect. This is the SEO spoke for Kahf Kids, the Islamic kids’ education app I migrated from Flutter to React Native / Expo after about eighteen months in production. The product runs curated video, Quran, books, games, and courses behind a parent-controlled PIN, and the web build ships out of the same React Native / Expo codebase as the mobile apps — documented across the cluster. This piece is narrow on purpose: the engineering decision that got a client-rendered kids’ video app actually indexed by Google — meta-injection into the SPA shell, not full server-side rendering.

The hard truth about a kids’ video catalog on the web is this: ship it as a pure client-rendered SPA and it is invisible. A crawler arrives at /video/{id}, the server returns the same empty index.html it returns for every URL, the JS bundle downloads and renders in a headless browser if you are lucky — and even then, the per-URL meta tags, the canonical URL, the structured data, and the sitemap entry are either missing or generic. Thousands of videos, zero indexable pages. Link previews in WhatsApp and Telegram show the app’s default title, not the video’s. Google’s video rich results never fire because there is no VideoObject markup. That is the default outcome for a React Native / Expo web build, and it is what this architecture had to fix.

The fix is split across two layers that cooperate. The backend generates a canonical sitemap tree — every video and playlist URL, paginated, with priorities and lastmods. A thin Fastify server that runs in front of the Expo web build injects per-route meta into the SPA’s index.html on every request — title, description, Open Graph, Twitter card, and schema.org VideoObject JSON-LD — pulled live from the backend’s metadata API. Crawlers and share-sheet fetchers get rich, per-URL, indexable HTML. Users get the SPA. No full SSR.

The SEO problem — why a SPA is invisible, and why SSR is overkill
#

A client-rendered SPA is invisible to crawlers for a reason that is structural, not a bug. The server returns the same index.html for every URL — that is the point of client-side routing. The meta tags inside that shell are the app’s defaults, not the route’s content. Googlebot does render the JS bundle in a secondary pass, but only for the routes it has already crawled, and only after a delay that can stretch to days. Worse, the messengers — WhatsApp, Telegram, Slack, Discord, iMessage — do not render JS at all; they fetch the HTML, read the Open Graph tags, and build the link preview from that. Send a /video/{id} link in WhatsApp and the recipient sees the app’s default og:title, not the video’s. That is the actual user-facing failure, not just a Google ranking issue.

The textbook fix is full server-side rendering — Next.js, Remix, a React server renderer. For a content catalog of thousands of videos with low per-page interactivity, full SSR is the wrong trade. You pay for a JavaScript runtime on the server, a render pipeline per request, hydration mismatch bugs, and infrastructure that scales with crawl traffic, not user traffic. The per-URL metadata — title, description, og:image, canonical, VideoObject JSON-LD — is the only thing that needs to be server-visible. The interactive video player does not. So the question becomes: how do you make just the metadata server-visible, for every URL, without paying for a full render server?

The SEO mechanism — meta-injection into the SPA shell
#

This is the load-bearing section. Two layers cooperate: the backend generates the sitemap tree; the Fastify server in front of the SPA does the per-request meta injection.

The paginated sitemap tree
#

The backend serves GET /api/sitemaps/{filename} — the source of truth for what the crawler discovers. It dispatches by filename and builds the sitemap tree from the in-memory content catalog:

  • index.xml — a sitemap index pointing to static.xml, videos-index.xml, playlists-index.xml. Cached 24h.
  • static.xml — a single urlset with the homepage URL, priority 1.0, changefreq=daily. Cached 24h.
  • videos-index.xml — a sitemap index of videos-1.xml … videos-{N}.xml, paginated at a fixed page size.
  • playlists-index.xml — same shape, paginating the playlist catalog.
  • videos-{page}.xml / playlists-{page}.xml — paginated urlsets. Each <url> carries <loc> (/video/{id} or /playlists/{id}), <priority> (0.8 for videos, 0.6 for playlists), <changefreq>weekly</changefreq>, and — for videos — <lastmod> derived from published_at.

The point is that every video and playlist URL in the catalog is in a sitemap. As the content catalog updates, the next sitemap rebuild picks up the new URLs and the new lastmods, automatically. There is no separate sitemap-build step to forget. The sitemap tree is generated from the same catalog the API serves from — single source of truth.

The Expo Fastify server: per-request meta injection
#

The web build runs behind a Fastify server (expo-app/server/) — the same server that serves the static bundle. It is small, it is TypeScript, and it does the SEO work the SPA cannot. Five services:

  • CrawlerDetector — keeps a list of eighteen known crawler and share-sheet user agents (Googlebot, bingbot, yandex, facebookexternalhit, Facebot, Twitterbot, LinkedInBot, WhatsApp, TelegramBot, Slackbot, discordbot, Pinterestbot, redditbot, Applebot, SkypeUriPreview, applebot-extended, google-read-aloud, whatsapp/2). I want to be honest about how it is used: it does not gate the meta-injection. It tags the response with a crawler-flag header for observability, that is it. The architecture deliberately does not bet on UA sniffing for correctness — see the wiring note below.
  • MetaInjector — reads the SPA’s index.html into memory once at boot, then exposes two injectors. injectHomepageMeta() produces the homepage HTML — title, description, OG tags, Twitter card, and a WebSite JSON-LD block. injectContentMeta(content, 'video' | 'playlist') produces the per-URL HTML. For a video, the OG type becomes video.other and the JSON-LD block is a schema.org VideoObjectname, description, thumbnailUrl, uploadDate, contentUrl, embedUrl, and a publisher Organization (the channel name when present, fallback to the app). For a playlist, the JSON-LD is an ItemList with numberOfItems. Image falls back to a default og-image when the video has no thumbnail. The injector strips the base shell’s existing SEO/OG/twitter/title tags before injecting, so the per-route set is the only one in the document — duplicate og tags break strict parsers, and WhatsApp in particular drops the whole preview when it sees two og:image sets.
  • KahfKidsApiClient — the backend client the injector uses to fetch the video or playlist record by id.
  • SitemapGenerator — proxies the backend’s sitemap endpoints through the web origin, with a 1-hour in-memory cache (CACHE_DURATION_MS = 60 * 60 * 1000) and a regex (^[a-z]+(-[a-z0-9]+)?$) that validates the filename before it is forwarded. So /sitemap.xml on the web origin hits backend /api/sitemaps/index.xml; /sitemaps/videos-1.xml hits backend /api/sitemaps/videos-1.xml. The web origin is the canonical public face; the backend is upstream. The regex is not paranoia — without it the proxy would forward arbitrary filenames to the backend, and a malformed request would surface as a 500 rather than an empty urlset. When the backend is unreachable, the proxy returns a valid empty <urlset> with a comment rather than an error XML, so a transient backend blip does not poison the cached sitemap.
  • HtmlCache — caches the injected HTML per content id, so a second fetch for the same video does not hit the backend.

The wiring in index.ts is the design point I want to underline. The / route always returns injected homepage meta. The setNotFoundHandler matches /video/{id} and /playlists/{id} and injects per-content meta on every request, regardless of user agent — not just when a crawler is detected. The comment in source says why: the iOS share sheet’s on-device preview fetch uses a Safari-style UA that UA sniffing would miss, and so do half the messenger unfurlers. Injecting for every fetcher is cheap — the result is cached per content id — and it is correct by default. Crawler detection becomes an observability signal, not a correctness gate.

Kahf Kids SEO request flow: meta injection and sitemap tree

That is the whole mechanism. A crawler arrives at /video/{id}. The Fastify setNotFoundHandler matches the route, the MetaInjector fetches the video record from the backend, strips the base shell’s SEO tags, injects the per-URL title/OG/Twitter/VideoObject-JSON-LD set, caches the rendered HTML, and returns it. The crawler reads a fully-formed, per-URL HTML document with VideoObject structured data. A real user hits the same URL, gets the same HTML, and the SPA hydrates around the meta into the interactive video player. Same URL, same HTML, different consumers. No full SSR.

Outcome
#

Thousands of video and playlist URLs, each indexable with correct per-URL meta and a VideoObject rich-result payload. Link previews work in WhatsApp, Telegram, Slack, Discord, iMessage, and the rest of the unfurler matrix — not because each one was special-cased, but because the per-URL OG tags are correct by default. The sitemap tree covers every URL in the catalog and stays fresh with the content store, no separate build step. The web shell stays a client-rendered SPA — no server JS runtime, no hydration, no render pipeline scaling with crawl traffic.

The layering is what I want to underline as the engineering outcome. The backend owns the catalog and the sitemap tree; the Fastify server owns the per-request HTML assembly; the SPA owns the interactive player. Three responsibilities, three layers, no overlap. A new video lands in the catalog → the next sitemap rebuild includes it → the next crawl discovers it → the next share-sheet fetch returns its per-URL meta. No deploy orchestrates those steps; they fall out of the architecture. The server layer this all lives in is the web branch of the RN/Expo rebuild, documented separately.

Takeaways
#

  1. Meta-injection beats SSR when the per-URL metadata is the only server-visible thing. A video catalog does not need a React render server. It needs correct title/OG/JSON-LD per URL, served from a thin Fastify layer that reads the SPA shell once and rewrites <head> per route. The interactive player does not need to be server-rendered; the meta does.
  2. Paginate the sitemap tree from the content catalog, not from a separate build step. The sitemap endpoints derive their urlsets from the same catalog the API serves from. New content appears in the sitemap on the next request, with the right lastmod. A fixed page size is the only knob.
  3. Do not bet correctness on UA sniffing. Detecting crawlers feels like the elegant answer; it breaks the first time a share sheet shows up with a Safari UA. Inject per-route meta for every fetcher, cache per content id, and treat crawler detection as observability — never as the gate.
  4. Strip the base shell’s SEO tags before injecting. Two og:image sets in one document is a real bug, not a stylistic preference. WhatsApp drops the preview; other parsers pick one arbitrarily. The injector’s stripBaseSeoTags step is what makes the per-route set canonical.
  5. Use VideoObject JSON-LD for video rich results. Google’s video rich results key off VideoObjectname, description, thumbnailUrl, uploadDate, contentUrl, embedUrl. Without it, a video URL is just a web page. With it, the URL is eligible for the video carousel and the rich result.
  6. Proxy the sitemap through the web origin, with a cache and a regex. The web origin is the canonical face crawlers see; the backend is upstream. A 1-hour cache absorbs crawl bursts; a filename regex stops malformed requests before they reach the backend; an empty-urlset fallback keeps a transient backend blip from poisoning the cache.

If you’re scoping an SEO architecture for a client-rendered app
#

The decisions in this piece — the meta-injection layer in front of an SPA, the paginated sitemap tree, the per-route VideoObject JSON-LD — are the decisions I make on any content-heavy web product that ships client-rendered. If you are weighing meta-injection against full SSR, trying to get a React Native / Expo web build actually indexed, or repairing broken link previews across the messenger matrix, get in touch. I do this work.

Frequently Asked Questions
#

How is a React Native / Expo web app SEO-friendly? Not by rendering server-side. The web build is a normal Expo web SPA — what makes it indexable is a Fastify server in front of it that intercepts /video/{id} and /playlists/{id} requests, fetches the record from the backend, and injects per-route title, Open Graph, Twitter card, and schema.org VideoObject JSON-LD into the SPA’s index.html. Crawlers see a fully-formed HTML document per URL; users hydrate the same HTML into the interactive app.

How does Kahf Kids generate sitemaps? The backend serves GET /api/sitemaps/{filename}. The sitemap tree is index.xmlstatic.xml (homepage, priority 1.0), videos-index.xml (a sitemap index of videos-{N}.xml), playlists-index.xml (same for playlists). Each paginated file carries a fixed page size of URLs with priority (0.8 video / 0.6 playlist), changefreq=weekly, and lastmod from published_at. The Expo Fastify server proxies these at /sitemap.xml and /sitemaps/:filename.xml with a 1-hour in-memory cache.

What is meta injection vs SSR? SSR runs the React tree on the server and returns the rendered DOM per request — correct interactive HTML, but you pay for a JS runtime, a render pipeline, and hydration. Meta injection reads the SPA’s static index.html once, fetches the route’s metadata from the backend, and rewrites only the <head> — title, OG, Twitter, canonical, JSON-LD. The body is the SPA shell. Crawlers get correct per-URL meta and structured data; users get the SPA. No JS runtime on the server.

How are videos indexed for Google rich results? Per-URL VideoObject JSON-LD. When the Fastify server handles /video/{id}, the MetaInjector injects a VideoObject block with name, description, thumbnailUrl, uploadDate, contentUrl, embedUrl, and publisher. Combined with the per-URL sitemap entry and the canonical link tag, this makes the video URL eligible for Google’s video rich results and the video carousel — without server-rendering the player.

Why inject meta on every request, not just for detected crawlers? Because UA sniffing misses real fetchers. The iOS share sheet’s on-device preview fetch uses a Safari-style user agent; half the messenger unfurlers use unpredictable UAs; new crawlers appear. Betting correctness on a UA list means link previews silently break the first time an unrecognized fetcher arrives. Injecting per-route meta for every request is cheap — the result is cached per content id — and correct by default. Crawler detection stays in the code as an observability signal (it tags the response with which crawler was spotted), not as the gate that decides whether to inject.