Skip to content

feat(ui): add comprehensive SEO improvements - #1492

Merged
smakosh merged 1 commit into
mainfrom
feat/seo-improvements
Jan 23, 2026
Merged

smakosh merged 1 commit into
mainfrom
feat/seo-improvements

Conversation

@smakosh

@smakosh smakosh commented Jan 23, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Add dynamic sitemap.xml generation for all public routes (models, providers, blog, guides, features, etc.)
  • Add robots.txt with proper crawl directives (blocking dashboard, API, auth pages)
  • Fix web manifest with proper app name fields
  • Enhance root layout metadata with title template, keywords, and improved icons
  • Add Organization and WebSite JSON-LD schemas to root layout
  • Add Article schema with breadcrumbs to blog posts
  • Add TechArticle schema with breadcrumbs to guides
  • Add Product schema with breadcrumbs to model pages
  • Add Feature schema with breadcrumbs to feature pages
  • Add FAQ schema to homepage FAQ section

Test plan

  • Verify /sitemap.xml returns valid XML with all public routes
  • Verify /robots.txt returns proper crawl directives
  • Check structured data with Google Rich Results Test
  • Verify breadcrumb schemas render correctly in search previews

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

New Features

  • Branded the application as "LLM Gateway" across browser and system interfaces.
  • Enhanced structured data across content pages for improved search engine visibility.
  • Deployed sitemap and search engine directives for better indexing coverage.
  • Improved metadata for social media sharing and link previews.

✏️ Tip: You can customize this high-level summary in your review settings.

- Add dynamic sitemap.ts for all public routes
- Add robots.ts with proper crawl directives
- Fix web manifest with proper app name
- Enhance root layout metadata (title template, keywords, icons)
- Add Organization and WebSite JSON-LD schemas
- Add Article schema to blog posts with breadcrumbs
- Add TechArticle schema to guides with breadcrumbs
- Add Product schema to model pages with breadcrumbs
- Add Feature schema to feature pages with breadcrumbs
- Add FAQ schema to homepage FAQ section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jan 23, 2026 •

Copy link
Copy Markdown
Contributor

Walkthrough

This pull request adds comprehensive JSON-LD structured data across multiple pages for SEO and social sharing, updates the web manifest branding, implements sitemap and robots.txt files, and expands root-level metadata configuration with enhanced headers and schema organization.

Changes

Cohort / File(s) Summary
Branding & Web Manifest
apps/ui/public/favicon/site.webmanifest
Updated name and short_name from empty strings to "LLM Gateway" for proper app branding in browser shortcuts and manifests.
Page-Level JSON-LD Schemas
apps/ui/src/app/blog/[slug]/page.tsx, apps/ui/src/app/features/[slug]/page.tsx, apps/ui/src/app/guides/[slug]/page.tsx, apps/ui/src/app/models/[name]/page.tsx, apps/ui/src/app/models/[name]/[provider]/page.tsx
Injected Article/TechArticle, WebPage, Product, and BreadcrumbList JSON-LD schemas via dangerouslySetInnerHTML script tags to enhance SEO and social sharing metadata; includes structured data for content hierarchy, pricing, and page relationships.
Component-Level Schemas
apps/ui/src/components/landing/faq.tsx
Added FAQPage JSON-LD schema with faqData constant; injects structured Question/Answer pairs via script tag for improved rich snippet visibility.
Root Layout Metadata
apps/ui/src/app/layout.tsx
Significantly expanded metadata object: title now uses template structure, added keywords/authors/creator/publisher, extended icons array with multiple favicon variants, integrated manifest reference, enhanced openGraph/twitter metadata, added robots directives, and injected two Organization and WebSite JSON-LD scripts into document head.
SEO Infrastructure
apps/ui/src/app/robots.ts, apps/ui/src/app/sitemap.ts
Introduced robots.ts metadata route specifying crawl rules (allow "/" with excluded paths for dashboard/API/auth) and sitemap.xml reference; introduced sitemap.ts async generator aggregating 200+ pages from blogs, guides, changelogs, legal, migrations, models/providers, and features with proper change frequencies and lastModified timestamps.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

  • PR #1403: Adds guide page creation that is augmented by this PR's JSON-LD schema injection in apps/ui/src/app/guides/[slug]/page.tsx.
  • PR #1369: Introduces feature detail page that this PR enhances with structured WebPage JSON-LD metadata in apps/ui/src/app/features/[slug]/page.tsx.
  • PR #1482: Adds migrations content collection and landing hero migrations data that this PR's sitemap generator consumes and integrates.

Suggested reviewers

  • steebchen
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(ui): add comprehensive SEO improvements' accurately summarizes the primary objective of the PR, which is to add SEO enhancements across the UI application.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@smakosh smakosh self-assigned this Jan 23, 2026
@smakosh
smakosh added this pull request to the merge queue Jan 23, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@apps/ui/src/app/blog/`[slug]/page.tsx:
- Around line 54-63: The image object in the JSON-LD block uses
entry.image.width and entry.image.height directly, which can be undefined;
update that block (the spread that creates image: { "@type": "ImageObject", ...
}) to use the same fallback defaults as generateMetadata by substituting width
with entry.image.width ?? 800 and height with entry.image.height ?? 400 so the
schema never contains undefined dimensions.

In `@apps/ui/src/app/features/`[slug]/page.tsx:
- Around line 55-113: The JSON-LD injected via dangerouslySetInnerHTML for
breadcrumbSchema and featureSchema is not escaping `<`, triggering the Biome
security/noDangerouslySetInnerHtml lint; fix it by serializing the objects with
JSON.stringify and then escaping `<` characters (e.g., replace `<` with
`\u003c`) before assigning to __html for both breadcrumbSchema and featureSchema
so the script content cannot be used for tag injection; update the places where
dangerouslySetInnerHTML is set to use the escaped string produced from
breadcrumbSchema and featureSchema.

In `@apps/ui/src/app/guides/`[slug]/page.tsx:
- Around line 93-104: The JSON-LD injected with dangerouslySetInnerHTML (used
for articleSchema and breadcrumbSchema in page.tsx) isn't escaped and can be
vulnerable to </script> breakout; fix it by serializing each schema with
JSON.stringify(...) and then replacing "<" with the escaped sequence before
assigning to __html (e.g., JSON.stringify(articleSchema).replace(/</g,
'\\u003c') and likewise for breadcrumbSchema) so the script content remains
valid JSON-LD for crawlers while preventing script-breakout.

In `@apps/ui/src/app/layout.tsx`:
- Around line 90-122: Update the JSON-LD objects: in organizationSchema replace
the GitHub URL value "https://github.com/llmgateway/llmgateway" with
"https://github.com/theopenco/llmgateway", and in websiteSchema (potentialAction
-> target -> urlTemplate) change the query param from
"...?search={search_term_string}" to use the q parameter
"...?q={search_term_string}" so the urlTemplate matches the AllModels search
param; keep the rest of organizationSchema and websiteSchema unchanged.
🧹 Nitpick comments (1)
apps/ui/src/components/landing/faq.tsx (1)

12-46: Consider deriving accordion content from faqData to avoid duplication.

The FAQ data is defined twice: once in faqData (for JSON-LD) and again as hardcoded JSX in the accordion (lines 83-199). This creates a maintenance burden where updates to FAQ content require changes in two places that could drift out of sync.

The dangerouslySetInnerHTML warning from static analysis is a false positive here—the data is developer-controlled (hardcoded), not user input, so there's no XSS risk for JSON-LD injection.

Comment on lines +54 to +63
...(entry.image && {
image: {
"@type": "ImageObject",
url: entry.image.src.startsWith("http")
? entry.image.src
: `https://llmgateway.io${entry.image.src}`,
width: entry.image.width,
height: entry.image.height,
},
}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add fallback values for image dimensions to match generateMetadata behavior.

If entry.image exists but lacks width or height properties, the schema will contain undefined values, potentially causing malformed JSON-LD. The generateMetadata function already provides defaults (800, 400) for the same scenario.

Proposed fix
 		...(entry.image && {
 			image: {
 				"@type": "ImageObject",
 				url: entry.image.src.startsWith("http")
 					? entry.image.src
 					: `https://llmgateway.io${entry.image.src}`,
-				width: entry.image.width,
-				height: entry.image.height,
+				width: entry.image.width || 800,
+				height: entry.image.height || 400,
 			},
 		}),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
...(entry.image && {
image: {
"@type": "ImageObject",
url: entry.image.src.startsWith("http")
? entry.image.src
: `https://llmgateway.io${entry.image.src}`,
width: entry.image.width,
height: entry.image.height,
},
}),
...(entry.image && {
image: {
"@type": "ImageObject",
url: entry.image.src.startsWith("http")
? entry.image.src
: `https://llmgateway.io${entry.image.src}`,
width: entry.image.width || 800,
height: entry.image.height || 400,
},
}),
🤖 Prompt for AI Agents
In `@apps/ui/src/app/blog/`[slug]/page.tsx around lines 54 - 63, The image object
in the JSON-LD block uses entry.image.width and entry.image.height directly,
which can be undefined; update that block (the spread that creates image: {
"@type": "ImageObject", ... }) to use the same fallback defaults as
generateMetadata by substituting width with entry.image.width ?? 800 and height
with entry.image.height ?? 400 so the schema never contains undefined
dimensions.

Comment on lines +55 to +113
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://llmgateway.io",
},
{
"@type": "ListItem",
position: 2,
name: "Features",
item: "https://llmgateway.io/features",
},
{
"@type": "ListItem",
position: 3,
name: feature.title,
item: `https://llmgateway.io/features/${slug}`,
},
],
};

const featureSchema = {
"@context": "https://schema.org",
"@type": "WebPage",
name: feature.title,
description: feature.longDescription,
mainEntity: {
"@type": "SoftwareApplication",
name: `LLM Gateway - ${feature.title}`,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: feature.longDescription,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
featureList: feature.benefits.map((b) => b.title),
},
};

return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(featureSchema),
}}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Next.js App Router JSON-LD structured data best practices dangerouslySetInnerHTML

💡 Result:

Next.js App Router + JSON-LD (with dangerouslySetInnerHTML) best practices

1) Render JSON-LD as a <script type="application/ld+json"> in app/layout.tsx or the relevant page.tsx

Next.js’ current recommendation (App Router) is to output JSON-LD via a normal <script> tag from your layout/page component. This is the expected pattern for structured data. [1]

// app/products/[slug]/page.tsx
export default function Page() {
  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: "Example Product",
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
        }}
      />
      {/* page content */}
    </>
  )
}

Next.js explicitly warns that JSON.stringify alone doesn’t sanitize (XSS risk) and suggests scrubbing < (for example, replacing it with \u003c) or using a safer serializer (e.g., serialize-javascript) per your org’s security guidance. [1]

2) Don’t use JSON-LD for content that isn’t actually on the page

Google’s structured data policies: the markup must be a true representation of the visible page content; misleading/irrelevant markup can prevent rich results (or be treated as spam). [2]

3) Include required properties (and keep it valid JSON)

Rich results eligibility depends on providing required properties for the specific feature/type, and you should validate with Google’s Rich Results Test / Search Console tools. [2]

4) Placement: head or body is fine, inline is expected

Google supports JSON-LD embedded in a <script> tag and can read it in the <head> or <body> (inline, not as an external .json file for typical rich-result use). [3]

5) Prefer a plain <script> tag over next/script for JSON-LD

A common pitfall is trying to use next/script with type="application/ld+json" and hitting typing/prop issues; Next’s own guide demonstrates using a plain <script> tag with dangerouslySetInnerHTML instead. [1], [4]

Sources: Next.js JSON-LD guide [1], Google structured data policies/guidelines [2], Google intro to structured data formats/placement [3], Next.js discussion showing next/script friction [4].


🌐 Web query:

Biome linter dangerouslySetInnerHTML configuration rule settings

💡 Result:

Biome flags React’s dangerouslySetInnerHTML via these linter rules:

  • security/noDangerouslySetInnerHtml (recommended; default severity: error) (biomejs.dev)
  • security/noDangerouslySetInnerHtmlWithChildren (recommended; default severity: error) (biomejs.dev)

Configure in biome.json

Enable / set severity:

{
  "linter": {
    "rules": {
      "security": {
        "noDangerouslySetInnerHtml": "error",
        "noDangerouslySetInnerHtmlWithChildren": "error"
      }
    }
  }
}

(biomejs.dev)

Turn them off:

{
  "linter": {
    "rules": {
      "security": {
        "noDangerouslySetInnerHtml": "off",
        "noDangerouslySetInnerHtmlWithChildren": "off"
      }
    }
  }
}

(“off” is the standard way to disable a rule.) (biomejs.dev)

Note: these rules are also listed under the React domain’s rules (and are recommended there too). (next.biomejs.dev)

Citations:


🌐 Web query:

React script tag JSON-LD children vs dangerouslySetInnerHTML security

💡 Result:

Key difference

  • <script type="application/ld+json">{...}</script> (children/text): React treats the value as text content, not HTML. This means React’s normal escaping applies, which helps prevent XSS if the JSON contains characters that could otherwise break out of the script tag. (React’s docs emphasize that the dangerous case is when you bypass escaping with dangerouslySetInnerHTML.) [1]
  • dangerouslySetInnerHTML: React sets innerHTML directly, explicitly warning it can introduce XSS if the HTML isn’t fully trusted/sanitized. [1]

Why this matters specifically for JSON-LD

Google expects JSON-LD to be embedded as JSON inside a <script type="application/ld+json"> tag. [2]
The main XSS risk with JSON-in-script is not that JSON-LD “executes,” but that attacker-controlled content could inject </script> (or similar) to terminate the tag and inject real HTML/script unless it’s safely escaped. This is an HTML script-element escaping issue, not a JSON-LD-specific one. [3]

Practical guidance

Prefer “children string” when you can (it keeps React’s escaping in place):

<script type="application/ld+json">
  {JSON.stringify(schemaObject)}
</script>

Use dangerouslySetInnerHTML only when necessary (e.g., framework/head constraints), and then treat it like any other raw HTML sink:

  • Only inject trusted data, or sanitize/escape appropriately (at minimum ensure </script can’t appear unescaped). React explicitly warns this prop can create XSS. [1]

Sources

[1] React docs: dangerouslySetInnerHTML “should be used with extreme caution” due to XSS risk, and you can’t combine it with children — https://react.dev/reference/react-dom/components/common
[2] Google Developers: JSON-LD is placed in a <script type="application/ld+json"> tag — https://developers.google.com/custom-search/docs/structured_data
[3] W3C public-schemaorg thread: escaping in <script> elements is governed by HTML parsing concerns (script tag termination), not JSON-LD — https://lists.w3.org/Archives/Public/public-schemaorg/2015Jun/0036.html


🏁 Script executed:

cat -n apps/ui/src/app/features/[slug]/page.tsx | sed -n '55,113p'

Repository: theopenco/llmgateway

Length of output: 1736


🏁 Script executed:

find . -name "biome.json" -o -name "biome.jsonc" | head -5

Repository: theopenco/llmgateway

Length of output: 76


🏁 Script executed:

cat biome.json 2>/dev/null | head -50

Repository: theopenco/llmgateway

Length of output: 1457


🏁 Script executed:

cat biome.json | tail -n +35

Repository: theopenco/llmgateway

Length of output: 877


Add security escaping to JSON-LD serialization to satisfy Biome lint.

Biome flags dangerouslySetInnerHTML at lines 104 and 110 via the security/noDangerouslySetInnerHtml rule. The recommended fix per Next.js docs is to escape < characters in the JSON output to prevent script-tag injection, not to replace dangerouslySetInnerHTML with script children.

✅ Suggested fix
 return (
 	<>
 		<script
 			type="application/ld+json"
 			dangerouslySetInnerHTML={{
-				__html: JSON.stringify(breadcrumbSchema),
+				__html: JSON.stringify(breadcrumbSchema).replace(/</g, "\\u003c"),
 			}}
 		/>
 		<script
 			type="application/ld+json"
 			dangerouslySetInnerHTML={{
-				__html: JSON.stringify(featureSchema),
+				__html: JSON.stringify(featureSchema).replace(/</g, "\\u003c"),
 			}}
 		/>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://llmgateway.io",
},
{
"@type": "ListItem",
position: 2,
name: "Features",
item: "https://llmgateway.io/features",
},
{
"@type": "ListItem",
position: 3,
name: feature.title,
item: `https://llmgateway.io/features/${slug}`,
},
],
};
const featureSchema = {
"@context": "https://schema.org",
"@type": "WebPage",
name: feature.title,
description: feature.longDescription,
mainEntity: {
"@type": "SoftwareApplication",
name: `LLM Gateway - ${feature.title}`,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: feature.longDescription,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
featureList: feature.benefits.map((b) => b.title),
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(featureSchema),
}}
/>
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: "https://llmgateway.io",
},
{
"@type": "ListItem",
position: 2,
name: "Features",
item: "https://llmgateway.io/features",
},
{
"@type": "ListItem",
position: 3,
name: feature.title,
item: `https://llmgateway.io/features/${slug}`,
},
],
};
const featureSchema = {
"@context": "https://schema.org",
"@type": "WebPage",
name: feature.title,
description: feature.longDescription,
mainEntity: {
"@type": "SoftwareApplication",
name: `LLM Gateway - ${feature.title}`,
applicationCategory: "DeveloperApplication",
operatingSystem: "Web",
description: feature.longDescription,
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "USD",
},
featureList: feature.benefits.map((b) => b.title),
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema).replace(/</g, "\\u003c"),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(featureSchema).replace(/</g, "\\u003c"),
}}
/>
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 103-103: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)


[warning] 109-109: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.1.2)

[error] 104-104: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)


[error] 110-110: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

🤖 Prompt for AI Agents
In `@apps/ui/src/app/features/`[slug]/page.tsx around lines 55 - 113, The JSON-LD
injected via dangerouslySetInnerHTML for breadcrumbSchema and featureSchema is
not escaping `<`, triggering the Biome security/noDangerouslySetInnerHtml lint;
fix it by serializing the objects with JSON.stringify and then escaping `<`
characters (e.g., replace `<` with `\u003c`) before assigning to __html for both
breadcrumbSchema and featureSchema so the script content cannot be used for tag
injection; update the places where dangerouslySetInnerHTML is set to use the
escaped string produced from breadcrumbSchema and featureSchema.

Comment on lines +93 to +104
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the Biome configuration in the repo
find . -maxdepth 3 -name "biome.json" -o -name "biome.jsonc" | head -5

Repository: theopenco/llmgateway

Length of output: 76


🏁 Script executed:

# Search for any linting rules related to dangerouslySetInnerHTML
rg "dangerouslySetInnerHTML" --type json --type jsonc -A 2 -B 2

Repository: theopenco/llmgateway

Length of output: 93


🏁 Script executed:

# Check if there are other uses of dangerouslySetInnerHTML in the codebase to understand the pattern
rg "dangerouslySetInnerHTML" --type tsx --type ts | head -20

Repository: theopenco/llmgateway

Length of output: 91


🌐 Web query:

Next.js App Router JSON-LD structured data best practice pattern recommended approach

💡 Result:

Recommended pattern (Next.js App Router)

1) Render JSON-LD as an inline <script type="application/ld+json"> from your app/…/page.tsx (or layout.tsx).
Next.js’ current guidance is to render structured data directly as a <script> tag in layout.js or page.js (App Router) and inject the JSON via dangerouslySetInnerHTML. Also sanitize the payload (at minimum, escape < to \u003c) to reduce XSS risk when using JSON.stringify. [1]

// app/products/[id]/page.tsx
export default async function Page({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id)

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.name,
    image: product.image,
    description: product.description,
  }

  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{
          __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
        }}
      />
      {/* page content */}
    </section>
  )
}

2) Put the script in layout.tsx when it’s site-wide; keep it in page.tsx when it’s route-specific.
This matches the “layout vs page” recommendation in the Next.js guide. [1]

3) Prefer JSON-LD over microdata/RDFa.
Google recommends JSON-LD in general because it’s easiest to implement/maintain, and it can live in either <head> or <body> (as long as it’s valid and present in the rendered DOM). [2]

4) Validate your markup.
Use Google’s Rich Results Test and/or the Schema Markup Validator (Next.js explicitly recommends these). [1]

Optional (but common) refinement: type your JSON-LD with schema-dts to catch mistakes at build time. [1]

Sources

[1] Next.js Docs – “Guides: JSON-LD” (updated Sep 30, 2025) (nextjs.org)
[2] Google Search Central – “Intro to How Structured Data Markup Works” (developers.google.com)

Citations:


🏁 Script executed:

cat ./biome.json | head -100

Repository: theopenco/llmgateway

Length of output: 1838


🏁 Script executed:

# Check the actual file in question to see current implementation
cat -n apps/ui/src/app/guides/[slug]/page.tsx | sed -n '85,115p'

Repository: theopenco/llmgateway

Length of output: 1100


🌐 Web query:

React dangerouslySetInnerHTML vs script children JSON parsing safety comparison

💡 Result:

Key difference: HTML parsing/execution vs text + JSON parsing

Pattern What React does XSS / code-execution risk When it’s “safe enough”
dangerouslySetInnerHTML={{__html: htmlString}} Bypasses React’s escaping and assigns directly to the DOM’s HTML sink. React even requires the {__html: ...} wrapper to signal danger. High if htmlString can contain attacker-controlled markup/attributes (e.g., event handlers, javascript: URLs, etc.). This is the classic React XSS footgun. [2][5] Only with trusted HTML or robust HTML sanitization before injection. [2][5]
<script type="application/json">{jsonString}</script> then JSON.parse(script.textContent) When you pass a string as JSX children, React escapes it (renders it as text, not HTML). [1] The browser treats type="application/json" script contents as inert data, not executable JS. Generally low, because you’re not creating an HTML sink, and JSON.parse is the recommended safe parser (vs eval). [6] Still: if you later push parsed fields into innerHTML / dangerouslySetInnerHTML, you can reintroduce XSS. Good for embedding “initial state” / config blobs, as long as you keep it inert (e.g., application/json) and treat parsed values safely downstream. [6]

Important gotchas (where people get hurt)

  1. A <script> without an inert type is executable by design. If you render <script>{attackerControlledString}</script>, React escaping doesn’t “save” you—the browser will execute whatever script text you put there.
  2. Breaking out of a <script> tag is a real concern when embedding JSON into HTML. If the embedded JSON contains </script>, it can terminate the tag early and inject markup/script. Libraries exist specifically to make JSON safe to embed inside script elements by ensuring substrings like </script> don’t appear. [7]
  3. JSON.parse is preferred over eval for security. OWASP explicitly recommends using JSON.parse for JSON parsing (and not eval). [6]

Practical recommendation

  • If you need to render rich HTML: avoid dangerouslySetInnerHTML unless you sanitize and understand the risk. [2][5]
  • If you only need to pass data to the client: prefer inert JSON in <script type="application/json"> + JSON.parse(textContent), and ensure your embedding strategy can’t be broken by </script> (or use a proven “safe JSON for HTML/script embedding” utility). [1][6][7]

Sources: React escaping in JSX [1]; React dangerouslySetInnerHTML XSS risk [2][5]; OWASP on JSON.parse vs eval [6]; OWASP JSON embedding safety properties [7].


🏁 Script executed:

# Check Biome's security rules more carefully
rg "dangerouslySetInnerHTML|noDangerousInnerHtml" --type json

Repository: theopenco/llmgateway

Length of output: 46


🏁 Script executed:

# Search for any eslint/biome configurations that might override rules
rg "dangerouslySetInnerHTML" --type js --type json -i | head -20

Repository: theopenco/llmgateway

Length of output: 46


Add escaping to JSON-LD to prevent script breakout, but keep dangerouslySetInnerHTML.

The current code uses dangerouslySetInnerHTML without escaping, which leaves it vulnerable to </script> breakout if the schema contains untrusted content. However, the suggested alternative (rendering JSON as script children) won't work—React will escape the JSON string, breaking its parsing by search engines.

Per Next.js official guidance, add escaping to safely handle the < character:

✅ Correct fix (escaping, not removal)
			<script
				type="application/ld+json"
				dangerouslySetInnerHTML={{
-					__html: JSON.stringify(articleSchema),
+					__html: JSON.stringify(articleSchema).replace(/</g, "\\u003c"),
				}}
			/>
			<script
				type="application/ld+json"
				dangerouslySetInnerHTML={{
-					__html: JSON.stringify(breadcrumbSchema),
+					__html: JSON.stringify(breadcrumbSchema).replace(/</g, "\\u003c"),
				}}
			/>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleSchema).replace(/</g, "\\u003c"),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbSchema).replace(/</g, "\\u003c"),
}}
/>
🧰 Tools
🪛 ast-grep (0.40.5)

[warning] 94-94: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)


[warning] 100-100: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.1.2)

[error] 95-95: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)


[error] 101-101: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

🤖 Prompt for AI Agents
In `@apps/ui/src/app/guides/`[slug]/page.tsx around lines 93 - 104, The JSON-LD
injected with dangerouslySetInnerHTML (used for articleSchema and
breadcrumbSchema in page.tsx) isn't escaped and can be vulnerable to </script>
breakout; fix it by serializing each schema with JSON.stringify(...) and then
replacing "<" with the escaped sequence before assigning to __html (e.g.,
JSON.stringify(articleSchema).replace(/</g, '\\u003c') and likewise for
breadcrumbSchema) so the script content remains valid JSON-LD for crawlers while
preventing script-breakout.

Comment on lines +90 to 122
const organizationSchema = {
"@context": "https://schema.org",
"@type": "Organization",
name: "LLM Gateway",
url: "https://llmgateway.io",
logo: "https://llmgateway.io/favicon/android-chrome-512x512.png",
description:
"Route, manage, and analyze your LLM requests across multiple providers with a unified API interface.",
sameAs: [
"https://twitter.com/llmgateway",
"https://github.com/llmgateway/llmgateway",
],
contactPoint: {
"@type": "ContactPoint",
email: "contact@llmgateway.io",
contactType: "customer support",
},
};

const websiteSchema = {
"@context": "https://schema.org",
"@type": "WebSite",
name: "LLM Gateway",
url: "https://llmgateway.io",
potentialAction: {
"@type": "SearchAction",
target: {
"@type": "EntryPoint",
urlTemplate: "https://llmgateway.io/models?search={search_term_string}",
},
"query-input": "required name=search_term_string",
},
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check models page for search parameter handling
cat apps/ui/src/app/models/page.tsx

Repository: theopenco/llmgateway

Length of output: 1343


🏁 Script executed:

# Search for search parameter usage across the codebase
rg "searchParams|search_term" apps/ui/src/app/

Repository: theopenco/llmgateway

Length of output: 3570


🏁 Script executed:

# Check for any GitHub repository reference
rg "github.com/llmgateway" --type ts --type tsx

Repository: theopenco/llmgateway

Length of output: 91


🏁 Script executed:

# Check git remote to verify actual repository
git config --get remote.origin.url 2>/dev/null || echo "Not a git repo or no origin"

Repository: theopenco/llmgateway

Length of output: 107


🏁 Script executed:

# Check the AllModels component for search functionality
find apps/ui/src/components -name "*all-models*" -o -name "*AllModels*"

Repository: theopenco/llmgateway

Length of output: 108


🏁 Script executed:

# Search for search implementation in models components
rg -n "search" apps/ui/src/components/models/ -A 3 -B 1

Repository: theopenco/llmgateway

Length of output: 14002


Fix two issues in the JSON-LD schemas: incorrect GitHub URL and mismatched search parameter.

  1. Line 100 - GitHub URL: Change https://github.com/llmgateway/llmgateway to https://github.com/theopenco/llmgateway (the repository uses the theopenco organization).

  2. Line 118 - SearchAction parameter: Change urlTemplate: "https://llmgateway.io/models?search={search_term_string}" to use ?q= instead, since the AllModels component reads the query parameter as q (see all-models.tsx line 126: searchParams.get("q")). The template should be "https://llmgateway.io/models?q={search_term_string}" to match the actual search implementation.

The dangerouslySetInnerHTML warnings are false positives—all data is developer-controlled.

🤖 Prompt for AI Agents
In `@apps/ui/src/app/layout.tsx` around lines 90 - 122, Update the JSON-LD
objects: in organizationSchema replace the GitHub URL value
"https://github.com/llmgateway/llmgateway" with
"https://github.com/theopenco/llmgateway", and in websiteSchema (potentialAction
-> target -> urlTemplate) change the query param from
"...?search={search_term_string}" to use the q parameter
"...?q={search_term_string}" so the urlTemplate matches the AllModels search
param; keep the rest of organizationSchema and websiteSchema unchanged.

Merged via the queue into main with commit dde0a30 Jan 23, 2026
8 checks passed
@smakosh
smakosh deleted the feat/seo-improvements branch January 23, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant