The previous release shipped a batch of SEO foundation work. This one is the result of pointing two automated review passes at that work before calling it done: a full SEO audit against a running production build, and a security review of the diff. Between them they surfaced one latent XSS sink, one cost-abuse vector on a new public route, and a handful of indexing and crawl issues that a manual click-through does not catch. All are fixed here. The pattern across the findings is the one this template exists to address: code that builds clean and passes a manual check, then turns into a liability the moment a buyer feeds it real data.
Every page renders structured data into a <script type="application/ld+json"> block. The serializer was plain JSON.stringify, which does not escape angle brackets. A value containing a closing script tag would terminate the block early and let whatever followed run as markup. Today every value comes from config.ts or git-controlled MDX, so the live template is not exploitable. But this is a template: the day a buyer wires a post title, author, or description to a CMS or a user-editable field, the sink activates, and the marketing routes run with a CSP that permits inline scripts, so there is no backstop.
All 13 JSON-LD sites now serialize through one helper that escapes the three dangerous characters as unicode sequences:
export function safeJsonLd(data: unknown): string {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
}
The escapes are valid JSON and parse identically for search engines. Verified: the output neutralizes a </script>-based payload, roundtrips to the same object, and every rendered JSON-LD block still validates.
The per-post OG image route now refuses unknown slugs
The new /blog/[slug]/og.png route rendered a fallback image for any slug it did not recognize. Because the route is unauthenticated and each unique URL misses the CDN cache, an attacker could loop requests with random slugs and force a full image render (font subsetting plus layout) per request, billing a function invocation each time. The route now returns 404 for unknown slugs before rendering anything, and known slugs are pre-rendered at build time, so the dynamic render path is unreachable for anything that is not a real post.
The blog renderer routed external links through the hardened link component but passed everything else through unchecked. A non-http link in a post ([click](javascript:alert(1))) would render as a live anchor that executes on click. Internal links are now restricted to relative paths, page anchors, and mailto:; any other scheme is rewritten to a dead #. MDX is still treated as editorial content you author in git, so this is defense-in-depth for the day guest posts are accepted.
Social shares no longer all point at the homepage
openGraph.url was set once in the root layout, which means every page inherited it and advertised the homepage as its canonical social URL. Sharing the pricing page or a blog post would attribute the share back to /. The layout no longer sets it (Open Graph parsers correctly fall back to the page being fetched), and blog posts set their own.
Spinner-first HTML on every page
A single loading file at the app root wrapped every route, including fully static marketing and blog pages, in a loading shell. The real content shipped inside a hidden element with a spinner on top. Search crawlers that run JavaScript handle this fine, but the raw-HTML crawlers that feed AI training and citation corpora see the spinner first. The loading states now live inside the dashboard and admin sections, where the work being awaited is real and streaming actually helps.
Duplicate heading in blog posts
The table-of-contents label rendered as a second-level heading, and the component renders twice per post (one instance for desktop, one for mobile), so each post carried two identical headings in its outline. The label is now a plain paragraph.
Auth pages and freshness signals
Login, signup, and reset-password inherited the site's default meta description; each now has its own, and reset-password is excluded from indexing. Blog posts gained an optional updated frontmatter field that feeds the Article dateModified and the sitemap lastmod, so a revised post can signal freshness without faking its publish date.
The two outbound-link components were available but the template still hand-rolled link attributes in five places. The editorial-link component now backs the footer badge, the dashboard sidebar, the blog citations list, and the MDX renderer, so outbound-link policy has a single enforcement point instead of five copies that drift.
The files that a buyer's AI coding assistant reads on every session were teaching the old patterns, which means an assistant could have reintroduced the exact issues fixed above. They now encode the current rules: escape all structured data, derive crawler-facing URLs from one helper, guard public image routes, keep one top-level heading per page, and reach for the user-link component on any field that renders user input. The framework version and middleware filename references in the setup docs were corrected as well.
X-Powered-By is no longer sent. Legal pages dropped to a lower sitemap priority than commercial pages. The favicon is now sized to Google's minimum and an Apple touch icon ships alongside it. The ignored keywords meta tag was removed.
Public URLs, canonicals, robots rules, and the RLS posture are untouched. No database changes, no new dependencies, no new environment variables. The security-header baseline and the auth model are exactly as they were.
- Copy the
safeJsonLd() helper from lib/seo.ts and replace every JSON.stringify(...) inside a dangerouslySetInnerHTML JSON-LD block with it. This is the one fix to apply even if you skip the rest.
- If you added a per-post OG image route, copy the 404 guard and
generateStaticParams from app/(marketing)/blog/[slug]/og.png/route.tsx.
- Copy the internal-link scheme check from
components/blog/mdx-content.tsx.
- Remove
openGraph.url from your root layout and set it per-page where you define page-specific Open Graph data.
- If you have a root
app/loading.tsx, move it into your authenticated route groups.
No breaking changes. Run npm run build; the per-post OG route should show as statically pre-rendered, which confirms the guard and generateStaticParams landed.