← All posts
April 18, 20266 min readFeedbackIQ team

SEO without bloat: llms.txt, nano-banana OG images, a sitemap that knows about blog posts

robots.ts, sitemap.ts, a programmatic llms.txt route, one buildMetadata helper, and Gemini 2.5 Flash Image generating unique OG cards for every marketing page.

SEO without bloat: llms.txt, nano-banana OG images, a sitemap that knows about blog posts

SEO on a modern SaaS marketing site is about three things: the comparison page for every incumbent, a technical blog with actual signal, and the machine-readable surface that models (both crawler and agent) use to understand what the product is. I put the third one off for way too long — this post is the catch-up.

The four files every marketing site owes its future

  • robots.txt — who’s allowed where. Dashboard and /api are disallowed, everything else is open.
  • sitemap.xml — the canonical map of public URLs, regenerated from static content (competitors, blog posts) on every build.
  • llms.txt — the emerging standard from llmstxt.org for giving LLMs a structured summary of the site. Ignored by most crawlers today, read by agents increasingly often.
  • OpenGraph + Twitter + JSON-LD on every page. Nothing clever — just consistent.

In Next.js 16 these are all one file each. robots.ts and sitemap.ts export functions that return typed objects; llms.txt is a route handler returning plain text; metadata is an exported const per page.

One helper, every page

Every page’s metadata flows through a single buildMetadata() helper. It takes a title, description, path, optional image, optional type: “article”, and returns the full Next.js Metadata object with canonical URL, OpenGraph, Twitter, and optional noIndex. The result is that adding a new marketing page takes three lines of config and never ships with a broken OG card.

export const metadata: Metadata = buildMetadata({
  title: "FeedbackIQ vs. Canny",
  description: "Side-by-side: Canny's roadmap tool vs. FeedbackIQ's code-generating pipeline.",
  path: "/vs/canny",
  image: `${SITE_URL}/og/vs-canny.png`,
});

OG images from Gemini 2.5 Flash Image (“nano-banana”)

OG cards used to be the first thing I cut when shipping a new page. They’re nobody’s favorite work, and fonts in SVG templates are a special kind of pain. Gemini 2.5 Flash Image — colloquially nano-banana — made the cost of “generate a custom OG card for this page” drop to about two cents and zero design time.

We have a script at scripts/generate-hero-images.ts that reads the competitors and blog posts, builds a prompt for each one, and writes the resulting PNGs into public/og/. The default prompt is generic-brand; any slug with strong opinions can register an override in a BLOG_PROMPT_OVERRIDES map.

const res = await fetch(
  `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${API_KEY}`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] }),
  }
);

const json = await res.json();
const b64 = json.candidates[0].content.parts.find(p => p.inlineData?.data).inlineData.data;
await writeFile(outFile, Buffer.from(b64, "base64"));

The generic prompt nails ~60% of the cards. The rest get a hand-written override that reads more like a film brief than a prompt — isometric, futuristic, specific symbolism. Nano-banana is surprisingly good at following long visual prompts; almost all the iteration time is on the prompt, not the output.

llms.txt without ceremony

/llms.txt is a simple GET route that assembles a markdown-ish summary of what FeedbackIQ is, how it works, and links to every public page — comparison pages, blog posts, the home page. It’s cacheable (s-maxage=3600) and regenerates on deploy because the source arrays (competitors, posts) are bundled at build time.

export const dynamic = "force-static";

export async function GET() {
  const lines: string[] = ["# FeedbackIQ", "", "> ...summary...", ""];
  lines.push("## Pages");
  lines.push(`- [Home](${SITE_URL})`);
  for (const c of competitors) lines.push(`- [vs ${c.name}](${SITE_URL}/vs/${c.slug})`);
  for (const p of posts)       lines.push(`- [${p.title}](${SITE_URL}/blog/${p.slug})`);
  return new Response(lines.join("\n"), {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

Structured data on comparisons and posts

Each /vs/[slug] page emits a WebPage JSON-LD block with the FeedbackIQ SoftwareApplication as the main entity. Each blog post emits a BlogPostingblock with author, datePublished, and headline. It’s inline-scripted, not loaded from a CDN, because schema.org is literally just strings and shipping strings does not warrant a network round-trip.

What we got wrong

The first sitemap pointed at /dashboard and every authenticated route. Everyone who’s shipped Next.js before has done this at least once. Fix: filter the list to public routes before returning, and add noIndex via buildMetadata on everything behind auth. The crawler politeness cost of getting this wrong is small; the accidentally-leaking-private-urls cost is not.

That wraps the first arc of the journey. The next arc is ingesting feedback from sources beyond the widget — Sentry, support tickets, server logs — and feeding them through the same dedupe and PR pipeline. The dedupe layer was the foundation. Everything else rides on it.

Try FeedbackIQ

Drop a widget on your site, ship PRs from feedback

Claude reads the report, writes the fix, opens the PR on your repo. Dedupe with pgvector so the backlog doesn’t drown in duplicates.

Start for free