Skip to content
Growth
6 min read

SEO and GEO for app landing pages: guides, JSON-LD and llms.txt

What I put on seven static app landing pages so Google and AI answer engines can find and quote them: guides, FAQPage JSON-LD, llms.txt, robots and sitemaps.

Cover: an app landing page sending guides, structured data and llms.txt to search and AI crawlers
On this page
  1. Everything is static
  2. Guides that answer a real question
  3. Structured data built from the same dictionary
  4. hreflang, x-default and one lesson from Search Console
  5. Sitemaps, robots and llms.txt
  6. Takeaways

Each of my seven iOS apps has its own landing page on a .io.vn domain (why .io.vn): lockboxy.io.vn, stampzy.io.vn, talkzy.io.vn, baton.io.vn, minivid.io.vn, ringsy.io.vn and linkeeper.io.vn. Until recently they were mostly a home page plus the privacy, terms and support pages. That is fine for someone who already has the App Store link, and almost invisible to anyone who doesn't.

At the start of October I gave all seven the same treatment in one sweep. That meant a guides section, a full FAQ page, per-page metadata, structured data, sitemaps with images, an llms.txt, and a robots file that names the AI crawlers. The goal is two kinds of discovery: classic search (SEO) and being quoted by ChatGPT, Claude, Perplexity and Google's AI answers (GEO, generative engine optimization).

This post covers what is on those sites and why, taken from the landing repos and their commit history.

Everything is static

All seven landings are Next.js App Router sites with two locales, /vi and /en (Vietnamese is the default on five of them). Every route that matters is prerendered at build time. Guide pages use generateStaticParams with dynamicParams = false, and the commit messages say it plainly: "All new routes are prerendered (SSG); no request-time rendering."

For crawlers this matters more than any tag. A bot that does not run JavaScript still gets the full article, the FAQ answers and the JSON-LD in the first HTML response. There is no request-time rendering to be slow and no client-side fetch that can fail.

Diagram of the SEO and GEO surface of one landing: pages, JSON-LD, sitemap and llms.txt, with robots.txt allowing search and AI crawlers
Everything a crawler can read, in the first HTML response

Guides that answer a real question

The biggest change is content. Each landing now has 21 bilingual guides, and Stampzy has 24. They were added in three rounds: a first batch with the SEO work, six more alongside the FAQ and llms.txt, and twelve more with images in every guide, plus comparison and use-case pages.

The topics are what people actually search for. They are not about the app itself:

  • Stampzy: how to add the date, time and location to iPhone photos, photo attendance, hourly timesheets in Excel, and Vietnam's new addresses after the 2025 province merger.
  • Talkzy: words per minute and script length, livestream selling scripts, and using an iPad or a second iPhone as a teleprompter.
  • Baton: newborn sleep, pumping and milk storage, splitting night shifts, and growth charts.

Each guide follows the same rules, all written into the commit messages:

  1. Answer first. The intro gives the short answer before any detail, because that is the paragraph an answer engine lifts.
  2. Question headings. Section titles use the same wording people type.
  3. Works without the app. The Talkzy commit says each guide "works without the app and ends with what Talkzy actually ships". Baton's guides have pen-and-paper steps.
  4. Only shipped features. The closing section names features that exist on main, nothing on the roadmap.
  5. Health topics carry a disclaimer. Baton's guides keep a not-medical-advice note and cite AAP, AASM, WHO, CDC and Bộ Y tế by name.
  6. Internal links. Each guide links to two or three related guides and to the FAQ.
Diagram of how each guide is written: answer first, question headings, works without the app, only shipped features, related links, Article JSON-LD
The same six rules on every guide

Slugs are localized too. Stampzy's guide registry maps one stable id to a Vietnamese slug and an English slug, for example cham-cong-bang-hinh-anh and photo-attendance-check-in. The language switcher can then jump between the two versions without loading the article text into the browser.

Each guide also gets its own 1200×630 share card. Stampzy's pair the guide's title with a real app screen, and the card doubles as the guide's first image in the sitemap and the JSON-LD.

The share card of a Stampzy guide: the title on the left and the app's stamp-existing-photos screen on the right
A real guide card from stampzy.io.vn

Structured data built from the same dictionary

Every page carries JSON-LD in a <script type="application/ld+json">, and it is built from the same dictionary the page renders, so the two cannot drift apart.

  • Home: a WebSite, the app as a MobileApplication or SoftwareApplication, and the home FAQ as an FAQPage.
  • /faq: an FAQPage with every question on the page plus a BreadcrumbList. Baton's FAQ has 18 questions in five groups, Talkzy's has 16 in four.
  • Guides: an Article (Minivid uses BlogPosting) with the dates, author and images, plus a breadcrumb trail back through the guides index.

The guide and FAQ graphs point at the app by @id, so a crawler sees one app entity that the site, the FAQ and every guide are about:

ts
{
  "@type": "FAQPage",
  "@id": `${url}#faq`,
  inLanguage: locale,
  about: { "@id": `${SITE_URL}/#app` },
  mainEntity: items.map((i) => ({
    "@type": "Question",
    name: i.q,
    acceptedAnswer: { "@type": "Answer", text: i.a },
  })),
}

One rule sits as a comment in Stampzy's structured-data file: never add aggregateRating or review. The app has no real ratings yet, and invented ones are exactly what the site refuses to show. Talkzy's helper also serialises the JSON with < escaped, so a stray character in the copy cannot break out of the script tag.

hreflang, x-default and one lesson from Search Console

Each page declares its canonical URL and its vi/en alternates. It also declares x-default, and that one has a story behind it.

On 22 September, Search Console reported that Google had rejected the Lockboxy /vi page's declared canonical and picked the bare https://lockboxy.io.vn/ instead. That root URL only redirects based on the browser language, so the Vietnamese home page was not indexed at all. The fix was to declare the locale-less URL as x-default, the neutral entry point for the language set, instead of letting it look like a competing copy:

ts
languages: {
  vi: `${siteUrl}/vi${path}`,
  en: `${siteUrl}/en${path}`,
  "x-default": `${siteUrl}${path || "/"}`,
},

All seven landings now declare x-default, and so does the apps showcase. Whether Google revises its choice is Google's call and takes weeks to show, but the contradiction is gone.

On ownership: the Lockboxy landing reads its Google and Bing verification tokens from environment variables, so adding one is a hosting setting rather than a commit. A comment in the code explains why Bing matters too: ChatGPT search runs on Bing's index. If you can edit your DNS, a Search Console Domain property verified with a TXT record is the simpler general option. It covers http, https and every subdomain in one property, which suits a site that redirects / to a locale.

Sitemaps, robots and llms.txt

Sitemap. sitemap.ts lists every indexable page in both locales, each with its hreflang alternates. Every guide also lists its cover and in-article pictures as image entries. Guides carry their real modified date. In Stampzy, two hand-off routes whose content lives in the URL fragment are left out on purpose, since they are noindex.

Robots. Everyone is allowed. On top of that, the AI crawlers are named explicitly: GPTBot, OAI-SearchBot, ChatGPT-User, PerplexityBot, ClaudeBot, Claude-SearchBot, Google-Extended, Applebot-Extended, CCBot and a few more. The * rule already allows them. Naming them keeps that true if a stricter default is ever added.

ts
rules: [
  { userAgent: "*", allow: "/" },
  { userAgent: AI_CRAWLERS, allow: "/" },
],

llms.txt. Every landing serves /llms.txt, a short Markdown summary for language models. Stampzy's covers what the app is, key facts, release status, pricing, privacy, what it is not, and links to every page and guide in both languages. The longer /llms-full.txt carries the full FAQ and every guide as plain Markdown. It is never written by hand: a script (or a force-static route in Minivid and Ringsy) generates it from the same dictionaries and guide data as the pages. In Stampzy, npm run test:llms fails if the committed files are stale.

The "what it is not" section is the part I care about most. Stampzy's says the photo code is a consistency code, not a tamper-proof certificate, and "Verified time" is not legal evidence. If an AI is going to describe my app, I would rather hand it the limits myself.

IndexNow. Lockboxy also pings IndexNow after a deploy is live, so Bing refetches pages instead of waiting for a crawl. It reads the URL list from the deployed sitemap. It deliberately runs as a separate command and not as a build step: pinging during the build would invite Bing to re-read the old version still online.

Takeaways

  • Prerender everything. The cheapest SEO and GEO win is HTML that already contains the answer.
  • Write guides about the user's problem, answer first, and make them useful without your app.
  • Generate JSON-LD and llms.txt from the same data as the page, and never put a rating you don't have into structured data.
  • Declare x-default when the root URL redirects by language.
  • Allow AI crawlers by name, and tell them what your app does not do.

Discovery inside the App Store is the other half. I covered that in ASO in 10 locales without paid tools.

  • #SEO
  • #GEO
  • #Next.js
  • #Landing Pages
ShareXLinkedInFacebook
Dao Van Thuong

Mobile and fullstack engineer in Ho Chi Minh City. I build and ship my own indie iOS apps — Lockboxy, Linkeeper, Minivid, Ringsy, Talkzy, Baton and Stampzy.