Repository navigation
feat(ui): add comprehensive SEO improvements - #1492
Conversation
- 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>
WalkthroughThis 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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
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. Comment |
There was a problem hiding this comment.
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 fromfaqDatato 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
dangerouslySetInnerHTMLwarning 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.
| ...(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, | ||
| }, | ||
| }), |
There was a problem hiding this comment.
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.
| ...(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.
| 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), | ||
| }} | ||
| /> |
There was a problem hiding this comment.
🧩 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"
}
}
}
}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:
- 1: https://biomejs.dev/es/linter/rules/no-dangerously-set-inner-html/?utm_source=openai
- 2: https://biomejs.dev/es/linter/rules/no-dangerously-set-inner-html-with-children/?utm_source=openai
- 3: https://biomejs.dev/es/linter/rules/no-dangerously-set-inner-html/?utm_source=openai
- 4: https://biomejs.dev/linter?utm_source=openai
- 5: https://next.biomejs.dev/linter/domains/?utm_source=openai
🌐 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 withdangerouslySetInnerHTML.) [1]dangerouslySetInnerHTML: React setsinnerHTMLdirectly, 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
</scriptcan’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 -5Repository: theopenco/llmgateway
Length of output: 76
🏁 Script executed:
cat biome.json 2>/dev/null | head -50Repository: theopenco/llmgateway
Length of output: 1457
🏁 Script executed:
cat biome.json | tail -n +35Repository: 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.
| 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.
| <script | ||
| type="application/ld+json" | ||
| dangerouslySetInnerHTML={{ | ||
| __html: JSON.stringify(articleSchema), | ||
| }} | ||
| /> | ||
| <script | ||
| type="application/ld+json" | ||
| dangerouslySetInnerHTML={{ | ||
| __html: JSON.stringify(breadcrumbSchema), | ||
| }} | ||
| /> |
There was a problem hiding this comment.
🧩 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 -5Repository: 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 2Repository: 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 -20Repository: 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:
- 1: https://nextjs.org/docs/app/guides/json-ld
- 2: https://developers.google.com/search/docs/guides/intro-structured-data?utm_source=openai
🏁 Script executed:
cat ./biome.json | head -100Repository: 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)
- A
<script>without an inerttypeis 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. - 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] JSON.parseis preferred overevalfor security. OWASP explicitly recommends usingJSON.parsefor JSON parsing (and noteval). [6]
Practical recommendation
- If you need to render rich HTML: avoid
dangerouslySetInnerHTMLunless 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 jsonRepository: 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 -20Repository: 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.
| <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.
| 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", | ||
| }, | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check models page for search parameter handling
cat apps/ui/src/app/models/page.tsxRepository: 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 tsxRepository: 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 1Repository: theopenco/llmgateway
Length of output: 14002
Fix two issues in the JSON-LD schemas: incorrect GitHub URL and mismatched search parameter.
-
Line 100 - GitHub URL: Change
https://github.com/llmgateway/llmgatewaytohttps://github.com/theopenco/llmgateway(the repository uses thetheopencoorganization). -
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 asq(seeall-models.tsxline 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.
Summary
sitemap.xmlgeneration for all public routes (models, providers, blog, guides, features, etc.)robots.txtwith proper crawl directives (blocking dashboard, API, auth pages)Test plan
/sitemap.xmlreturns valid XML with all public routes/robots.txtreturns proper crawl directives🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
✏️ Tip: You can customize this high-level summary in your review settings.