SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now

Changelog

See what's new in SecureStartKit

Jun 17, 2026·v1.9.0

IndexNow submission for faster Bing and AI search indexing

feature

IndexNow: push your URLs to Bing and AI search

Every project built on SecureStartKit can now notify IndexNow with a single command, so Bing, Yandex, Naver, Seznam, Yep, and DuckDuckGo pick up new and changed pages within hours instead of waiting for a crawl.

Bing's index is the part that matters most in 2026: ChatGPT Search and Microsoft Copilot both retrieve from it, so getting into Bing quickly is how fresh pages become eligible for AI citations. (Google ignores IndexNow by design and keeps finding pages through your sitemap and internal links, so nothing about your Google setup changes.)

  • npm run indexnow submits every URL from your sitemap() after a deploy. The list is the sitemap itself, imported rather than duplicated, so the two can never drift out of sync.
  • Zero-config key handling. The first run generates your per-project domain-ownership key at public/<key>.txt. Commit it, deploy, and run the command again to submit. Each project gets its own key, so a key is never shared across domains.
  • npm run indexnow -- --dry-run prints the exact URL list without submitting anything.
  • Documented in CLAUDE.md so it stays a deliberate, post-deploy step rather than firing on every build.

No new dependencies and no new environment variables. See the IndexNow section in CLAUDE.md for the full workflow.

Jun 13, 2026·v1.8.11

Server-Side Price and Plan Binding at Checkout, Plus the Stripe Event Idempotency Ledger

securityfixed

What This Defends Against

A pass over the checkout and webhook path surfaced two issues in the payment flow. Neither is exotic, and both are the kind that pass a clean build and a manual purchase test, then matter the moment someone edits a request or a webhook event is redelivered.

  1. The price and the plan were chosen by the client. The checkout Server Action accepted a priceId and a productName from the request, and the webhook recorded and delivered from that product label. Stripe computes the charge amount from the Price object, so a buyer could not pay an arbitrary number, but they could pair a cheaper price with a more expensive plan by editing the request body: send the Starter price with the Pro product name. Today both plans hand out the same repository and a human grants access, so the live blast radius is a mislabeled record. The day plan-specific access is automated, the same request becomes a real entitlement bypass.

  2. The webhook had no idempotency ledger. Stripe delivers events at least once and retries for up to three days, so the same event arrives more than once as a matter of course. The handler kept no record of processed events, the delivery email was coupled to the purchase write, and a duplicate raised a primary-key error that returned a 500, which told Stripe to retry an event it had already processed.

What Ships in the Template

The client names a plan; the server owns the price (actions/billing.ts)

createCheckoutSession now accepts a single plan key. It looks that key up in config.billing.plans and takes both the Stripe price ID and the product identifier from the matched entry. The browser never sends a price ID or a product label, so it cannot pair a cheap price with an expensive plan, and an unknown plan key is rejected before any Stripe call.

const PLAN_BY_KEY = new Map(
  config.billing.plans.map((plan) => [plan.name.toLowerCase(), plan])
)

const plan = PLAN_BY_KEY.get(parsed.data.plan.toLowerCase())
if (!plan) return { error: 'Invalid plan' }
const priceId = plan.priceId.monthly   // from config, never from the request
const productId = plan.name.toLowerCase()

components/landing/Pricing.tsx calls it with a plan key instead of a price ID, so the visitor-facing flow is unchanged.

A Stripe event idempotency ledger (stripe_events)

A new stripe_events table records each processed event.id. The handler claims the ID before doing any work; a duplicate hits the primary key, is recognized as already processed, and returns 200 so Stripe stops retrying.

const { error: claimError } = await admin
  .from('stripe_events')
  .insert({ id: event.id, type: event.type })

if (claimError) {
  if (claimError.code === '23505') {
    return NextResponse.json({ received: true }) // already processed
  }
  return NextResponse.json({ error: 'Failed to record event' }, { status: 500 })
}

The purchase row is written with upsert, so a replay cannot throw on the key, and the delivery and notification emails are wrapped in their own try/catch, so an email-provider failure cannot fail the webhook and force a retry. If processing genuinely fails, the handler deletes the claim so Stripe can retry cleanly.

What Did NOT Change

The visitor-facing checkout looks identical: same pricing cards, same Buy Now button, same redirect to Stripe. Webhook signature verification, the RLS posture (deny-all on every table, service-role access in server code), and the security-header baseline are untouched. No new dependencies, no new environment variables.

Upgrading Existing Projects

  1. Run the stripe_events table creation from supabase/schema.sql in the Supabase SQL Editor (Dashboard, SQL Editor, New Query). It is RLS-enabled with no policies, like every other table.
  2. Update createCheckoutSession in actions/billing.ts to take a plan key and resolve the price ID and product from config.billing.plans, then change the Pricing.tsx call to send the plan key instead of a price ID.
  3. Copy the idempotency claim, the duplicate-key 200, the upsert purchase write, the email try/catch, and the claim release from app/api/webhooks/stripe/route.ts.
  4. If you use the generated Supabase types, add the stripe_events table to lib/supabase/database.types.ts.

Run npm run build to confirm. The webhook and checkout changes are inert until the stripe_events table exists, so you can stage the upgrade.

Jun 10, 2026·v1.8.10

JSON-LD XSS Hardening, OG Route Abuse Guard, and SEO Audit Fixes

securityfixedimproved

Why This Release Exists

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.

Security

JSON-LD now escapes its way out of the script tag

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.

MDX internal links can no longer carry a javascript: scheme

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.

Fixed

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.

Improved

Link-safety primitives are now wired in, not just shipped

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.

AI assistant guidance updated

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.

Smaller polish

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.

What Did NOT Change

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.

Upgrading Existing Projects

  1. 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.
  2. If you added a per-post OG image route, copy the 404 guard and generateStaticParams from app/(marketing)/blog/[slug]/og.png/route.tsx.
  3. Copy the internal-link scheme check from components/blog/mdx-content.tsx.
  4. Remove openGraph.url from your root layout and set it per-page where you define page-specific Open Graph data.
  5. 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.

Jun 10, 2026·v1.8.9

Per-Post OG Images, Schema Fixes, and a Sitemap That No Longer Advertises 404s

fixedimproved

Why This Release Exists

An SEO audit of the template foundation surfaced one real bug and a set of gaps that share a pattern: everything passes a manual click-through and a clean build, then shows up weeks later as Search Console warnings, missing rich results, or a generic preview card where a branded one should be. This release fixes the bug and closes the gaps. It was verified against Google's own documentation, not against SEO-blog folklore (which, this round, included a fabricated "LCP threshold dropped to 2.0s" claim that web.dev does not support).

Fixed

The sitemap emitted category URLs that 404

Version 1.8.7 moved blog category pages to slugged URLs, but app/sitemap.ts was still building category entries from the raw category name. A category named "Product Updates" produced /blog/category/Product%20Updates in the sitemap while the route only resolves /blog/category/product-updates. Every category URL in the sitemap was a 404, and persistent sitemap errors lower Google's trust in the whole file, which slows discovery of the URLs that are valid. Category entries now route through the same categoryToSlug() the page uses, so the sitemap, the route, and the canonical all agree.

One canonical origin for everything crawlers read

The sitemap, robots.ts, the RSS feed, and llms.txt each read process.env.NEXT_PUBLIC_APP_URL! directly. The non-null assertion only silences TypeScript; a missing env var at build time baked literal undefined/pricing URLs into the shipped sitemap. All crawler-facing URLs now flow through one helper:

export function getBaseUrl(): string {
  return process.env.NEXT_PUBLIC_APP_URL || `https://${config.domainName}`
}

A misconfigured deploy now degrades to the configured domain instead of shipping garbage URLs.

One h1 per page, everywhere

Three heading-outline problems, all at composition seams rather than inside any single component:

  • A # heading in MDX post content rendered a second h1 after the post title. The MDX renderer now demotes h1 to an anchor-carrying h2, same as the other headings.
  • The pricing page renders the pricing section with its visible header hidden, which silently removed the section h2 and left plan-name h3s hanging directly under the h1. The section now keeps a screen-reader-only h2 when the visible header is off.
  • The dashboard top bar used an h2 for the "Dashboard" label (layout chrome, not a content heading), and stat-card labels like "Total Users" were h3s. Both demoted to non-heading elements.

Improved

Every blog post gets its own Open Graph image

A new app/(marketing)/blog/[slug]/og.png route renders a branded 1200x630 card with the post title, category, and date, generated from config.appName like the rest of the template's image assets. Post metadata now points og:image, the Twitter card, and the Article structured data at the same stable URL.

This is an explicit route rather than the opengraph-image.tsx file convention for a reason worth knowing: Next.js appends a build hash to dynamic convention routes, so the URL cannot be referenced from JSON-LD. The explicit route trades a little convention magic for an addressable URL that meta tags and structured data can share.

The structured-data side matters because image is a required property for the Article rich result. Posts without a hand-made image were ineligible; now every post qualifies by default.

Structured data: authors are people, sites are entities

  • Article JSON-LD now credits post.author as a Person instead of always crediting the Organization, and emits dateModified. Named authorship is one of the clearer experience signals Google's quality-rater guidelines look for since the December 2025 update extended them to all competitive queries.
  • A WebSite entity is emitted alongside Organization on every page, which helps search and AI engines disambiguate the site as an entity. No SearchAction, since Google retired the sitelinks search box.

Icons Google can actually use

The generated favicon moved from 32x32 to 96x96 (Google Search requires favicons sized in multiples of 48px, minimum 48), and a 180x180 Apple touch icon now ships alongside it. Both render from config.appName, so they update with a one-line config change like the OG images do.

An explicit AI crawler policy

robots.ts now documents that the wildcard rule deliberately admits AI crawlers (GPTBot, ClaudeBot, PerplexityBot, CCBot, Google-Extended), which is the right default for a site that wants to be cited by AI search engines. The comment includes the exact block to add to opt out of training crawls per bot, plus the caveat people miss: Google-Extended controls Gemini training only, and blocking it does not remove you from Google Search or AI Overviews.

Small cleanups

  • Markdown images in posts now render with loading="lazy" and decoding="async", so an image-heavy post does not regress loading performance out of the box.
  • The keywords meta tag is gone from config and layout. Every major engine has ignored it since roughly 2009, and shipping it signals dated SEO defaults.
  • config.seo.ogImage pointed at a /og.png that never existed. The field is now optional and documented: the OG image is generated, and the static path only takes effect if you remove the generator.
  • twitter:site is emitted alongside twitter:creator.
  • The deprecated Edge runtime export was removed from the icon, OG image, and logo routes; they run on the default Node.js runtime.
  • Static sitemap entries no longer carry a hardcoded lastModified date that went stale the day it shipped.

What Did NOT Change

Public URLs, canonicals, robots disallow rules, and the existing structured data types are untouched. No database changes, no new dependencies, no new environment variables. The RLS posture and security headers are exactly as they were in 1.8.8.

Upgrading Existing Projects

  1. Copy lib/seo.ts (it gains getBaseUrl() and websiteJsonLd(), plus the Person author and image fallback in articleJsonLd), then switch app/sitemap.ts, app/robots.ts, app/feed.xml/route.ts, and app/llms.txt/route.ts to import getBaseUrl from it. Take the slugged category fix in sitemap.ts even if you skip everything else.
  2. Copy app/(marketing)/blog/[slug]/og.png/route.tsx and the openGraph.images / twitter block from the blog post page's generateMetadata.
  3. Copy app/icon.tsx, the new app/apple-icon.tsx, and the root app/layout.tsx metadata changes. If you customized config.seo, delete your keywords array and treat ogImage as optional.
  4. Copy the h1 and img mappings from components/blog/mdx-content.tsx.

No breaking changes. Run npm run build and check that /sitemap.xml lists your category pages with lowercase slugged URLs; that is the quickest end-to-end confirmation the upgrade landed.

Jun 10, 2026·v1.8.8

Open-Redirect Fix, Distributed Rate Limiting, and Stripe Webhook Idempotency

securityfixed

What This Defends Against

A security pass over the template surfaced four issues that range from a real phishing primitive to defense-in-depth gaps. None require an exotic attacker. All four are the kind of thing that passes a manual click-through and a clean build, then shows up when someone goes looking.

  1. Open redirect in the auth callback. The OAuth and password-recovery callback built its post-login redirect by concatenating a next query param straight onto the site origin. Because it was string concatenation, not an origin-checked resolve, an attacker controlled the final host. A value like next=@evil.com resolves to a URL whose host is evil.com (the site name becomes harmless userinfo), and next=.evil.com resolves to yoursite.com.evil.com. The redirect fires the instant after a successful login, which is the highest-trust moment for a "session expired, sign in again" phishing page on a lookalike domain. The reset-password flow already routes a next value through this callback, so the parameter was a live input.

  2. Rate limiting that did nothing on serverless. The login, signup, and reset limits were enforced in a per-process in-memory map. On Vercel each function instance has its own memory and instances scale horizontally, so the limits were per-instance, not global, and reset on every cold start. A distributed credential-stuffing or email-flooding attempt simply spread across instances. The IP used as the bucket key was also read from the leftmost x-forwarded-for value, which the client can set.

  3. A password-reset flow that dead-ended. The reset email linked to /settings/password, but that page never existed and no action consumed the updatePasswordSchema that was already defined. A user clicking a valid recovery link got an authenticated session and a 404, with no way to actually set a new password.

  4. A Stripe webhook with no replay protection. Stripe delivers events at-least-once and retries on any non-2xx, but the handler kept no record of processed events. The two money paths were upsert-keyed and so naturally idempotent, but nothing stopped a redelivered event from re-running any non-idempotent side effect added later. The same handler also wrote a purchase row with an empty user_id when checkout metadata was missing, and recorded a hardcoded product placeholder instead of what was actually bought.

What Ships in the Template

Same-site redirect validation in app/auth/callback/route.ts

The next parameter now passes through a safeRedirectPath() guard before it is used. The target must be a single-slash relative path. Anything that could escape to another host (a protocol-relative //, a backslash variant, or an @ userinfo trick) falls back to /dashboard.

function safeRedirectPath(next: string | null): string {
  if (!next) return '/dashboard'
  if (!next.startsWith('/')) return '/dashboard'
  if (next.startsWith('//') || next.startsWith('/\\')) return '/dashboard'
  if (next.includes('@') || next.includes('\\')) return '/dashboard'
  return next
}

Allowlist, not denylist: the path is required to be relative first, then the few characters that can still break same-origin are stripped. A malformed value degrades to the dashboard instead of erroring.

Distributed rate limiting with an in-memory fallback in lib/rate-limit.ts

rateLimit() keeps the same signature, so no call site changed. When UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are set, limits are enforced globally across every instance via Upstash Redis sliding windows. Without them, it falls back to the previous in-memory map for local development and single-instance deploys. If Redis is unreachable at request time, it fails open to the in-memory limiter rather than locking every user out of login.

export async function rateLimit(key, limit, windowSeconds) {
  if (redis) {
    try {
      const { success, remaining } = await getUpstashLimiter(
        limit,
        windowSeconds
      ).limit(key)
      return { success, remaining }
    } catch (err) {
      console.error('Upstash rate limit failed, falling back to in-memory:', err)
      return inMemoryRateLimit(key, limit, windowSeconds)
    }
  }
  return inMemoryRateLimit(key, limit, windowSeconds)
}

The IP source also changed: it now prefers x-real-ip (which Vercel sets to the true client IP) and, when reading x-forwarded-for, takes the last hop (the closest trusted proxy) instead of the client-supplied leftmost value.

A working password-reset destination

A new updatePassword Server Action validates the new password with Zod, confirms an authenticated recovery session before calling updateUser, and returns a generic result. A new /settings/password page (the exact path the reset email links to) renders an UpdatePasswordForm with a confirm-password match check. The recovery link now lands on a real "set a new password" screen.

A Stripe webhook idempotency ledger

A new stripe_events table records each processed event.id. The handler inserts the id before processing and acknowledges without re-running side effects when the primary-key insert conflicts, which is the dedup signal for a redelivery.

const { error: ledgerError } = await admin
  .from('stripe_events')
  .insert({ id: event.id, type: event.type })

if (ledgerError) {
  if (ledgerError.code === '23505') {
    return NextResponse.json({ received: true, duplicate: true })
  }
  console.error('Failed to record Stripe event:', ledgerError)
  return NextResponse.json({ error: 'Failed to record event' }, { status: 500 })
}

The checkout.session.completed path now refuses to write a purchase row when user_id is absent (log and skip instead of inserting an empty owner), and createCheckoutSession records the purchased price in checkout metadata so the row reflects what was actually bought. As a bonus, the handle_new_user trigger (a SECURITY DEFINER function) now pins SET search_path = '' so it cannot be hijacked by an attacker-created object earlier on the search path.

What Did NOT Change

Public surface. Every page, link, and route guard that already worked keeps the same observable behavior. The redirect guard only rejects targets that were never legitimate. The rate limiter is behavior-identical without Upstash credentials.

The CSP, the security headers, and the RLS posture (deny-all on every table, service-role access in server code) are untouched. The defense-in-depth auth re-checks in the dashboard and admin layouts are still the real enforcement; these fixes harden the layers in front of them.

Upgrading Existing Projects

  1. Copy the safeRedirectPath() helper into your app/auth/callback/route.ts and run the next parameter through it before NextResponse.redirect. This is the one fix to apply even if you skip the rest.
  2. Add @upstash/ratelimit and @upstash/redis, replace lib/rate-limit.ts with the pluggable version, and set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN (Vercel Marketplace ships Upstash Redis). Without the env vars the app still runs on the in-memory fallback. Update getClientIp() to prefer x-real-ip.
  3. If you use the password-reset flow, copy the updatePassword action, the /settings/password page, and the UpdatePasswordForm component.
  4. Run the stripe_events table creation from supabase/schema.sql in the Supabase SQL Editor, then copy the idempotency block and the empty-user_id guard into your webhook handler. Re-run the handle_new_user function definition to pick up the search_path pin.

No breaking changes. Run npm run typecheck to confirm. The rate-limit and webhook changes are inert until you add the env vars and the stripe_events table, so you can stage the upgrade in pieces.

Jun 3, 2026·v1.8.7

Crawl-Safe Route Guards, Slugged Blog Categories, and i18n Guardrails

fixedimproved

What This Defends Against

Three latent SEO bugs that don't surface in next dev, don't surface in npm run build, don't surface in a manual click-through, and only show up after a crawler (Semrush, Ahrefs, Screaming Frog) walks the production sitemap. By then the warnings are already attached to the domain and the recovery cycle is "ship a fix, wait two weeks for Google to recrawl, check."

The three failure modes that get pre-empted:

  1. Auth-gated routes silently swallowing public marketing pages. A protected route named /billing matched with pathname.startsWith('/billing') also matches a public /billing-explained, /billing-faq, or /billing-vs-stripe page and 307s it to /login. The marketing page sits in the sitemap, so the crawler reports it as "3xx redirect in sitemap" on every public variant.
  2. Blog category URLs that crawlers can't follow. A category named "News & Press" becomes /blog/category/News%20%26%20Press in the URL. Some crawlers strip URL encoding inconsistently, some refuse to recognize percent-encoded slugs as inlinks. Result: "canonical URL has no inlinks" on every category page, even though the blog index links to all of them.
  3. next-intl auto-emitted hreflang pointing at non-canonical URLs. When defineRouting() is added later for a second locale and page metadata still hardcodes alternates: { canonical: '/about' }, the /<locale>/about page self-canonicals back to /about. next-intl middleware happily emits Link: rel="alternate"; hreflang="<locale>" headers anyway, producing a broken cross-reference flagged as "hreflang to non-canonical pages" on every translated route.

What Ships in the Template

matchesRoute() helper in proxy.ts

Three route-guard checks in proxy.ts (protectedRoutes, authRoutes, adminRoutes) and the strict-CSP scoping check now route through a single helper:

const matchesRoute = (pathname: string, routes: string[]) =>
  routes.some((route) => pathname === route || pathname.startsWith(route + '/'))

Match is true on exact equality OR with a / boundary. Same behavior for legitimate subroutes (/dashboard/foo is still gated by /dashboard), but /dashboard-public is no longer collateral damage. Replaces three bare pathname.startsWith(route) calls that have been in the template since the original middleware shipped, including the strict-CSP scoping path.

The fix is purely a tightening of an existing check. Every project that was correctly naming routes (no protected/public prefix collisions) gets the same behavior. Every project that had a latent collision gets the public page back without changing config.

categoryToSlug() and getCategoryBySlug() in lib/blog.ts

Two new helpers and a rewired category page:

// lib/blog.ts
export function categoryToSlug(category: string): string {
  return category
    .toLowerCase()
    .normalize('NFKD')
    .replace(/[̀-ͯ]/g, '') // strip combining diacritics
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
}

export function getCategoryBySlug(slug: string): string | null {
  return getCategories().find((c) => categoryToSlug(c) === slug) ?? null
}

The blog index (app/(marketing)/blog/page.tsx) now links to /blog/category/${categoryToSlug(category)}. The category page (app/(marketing)/blog/category/[category]/page.tsx) generates static params from slugs, looks up the original category name via getCategoryBySlug() for display and post filtering, and 404s on unknown slugs. The breadcrumb JSON-LD and the visible <h1> use the original casing instead of the previous decoded.charAt(0).toUpperCase() + capitalize CSS combo, which silently lower-cased mid-word characters in acronyms like "API" or "ATS".

Pure string manipulation, no i18n or runtime dependencies. Categories with diacritics, ampersands, slashes, parentheses, and emoji all collapse to clean ASCII slugs.

CLAUDE.md: "Route Guard Prefix Bug" and "i18n Setup" sections

Two new sections document the failure modes so future Claude sessions and human developers don't reintroduce them:

  • Route Guard Prefix Bug explains the matchesRoute() helper, when to use it (every protected/auth/admin route gate, and any "is this URL under X" check including the strict-CSP scoping), and the exact failure mode it prevents.
  • i18n Setup (when adding next-intl) is gated behind "only applies if you actually add a [locale] segment, a defineRouting() config, and a real second locale." Single-locale projects can skip it. When a project does add internationalization, the section documents the four landmines: alternateLinks: false in defineRouting(), locale-aware canonicals via a lib/locale-alternates.ts helper, what to do for untranslated content under a secondary locale, and the locale-prefix-stripping pattern that proxy.ts route guards need before calling matchesRoute().

The SEO Rules section also gains a one-liner enforcing categoryToSlug() on all category URL constructions, and a one-liner enforcing matchesRoute() boundary matching on route gates.

What Did NOT Change

Public surface. Every page that already worked, every link that already worked, every route guard that was correctly named in protectedRoutes keeps the same observable behavior. The matchesRoute() change is a strict tightening, the slugify change emits cleaner URLs (the old encoded URLs return 404 on direct navigation, but those URLs were never linked from the template itself, only from Link components that now route through categoryToSlug()).

i18n is untouched. The template still ships a one-locale i18n/request.ts stub. The CLAUDE.md i18n section is documentation only, none of its rules activate until a project adds [locale] routing.

No dependency changes, no lockfile changes. The fixes are five files: proxy.ts, lib/blog.ts, app/(marketing)/blog/page.tsx, app/(marketing)/blog/category/[category]/page.tsx, CLAUDE.md.

Upgrading Existing Projects

  1. Copy the matchesRoute() helper from proxy.ts into your own proxy.ts (or middleware.ts if your project still uses the older name). Replace every routes.some((r) => path.startsWith(r)) and routes.some((r) => pathname.startsWith(r)) call with matchesRoute(path, routes). Audit any other startsWith route-scoping check (CSP, headers, A/B-test cohort gates) for the same boundary problem.
  2. Copy categoryToSlug() and getCategoryBySlug() from lib/blog.ts. Update your blog index page to wrap category href values with categoryToSlug(). Update your category dynamic route page to: emit slugs from generateStaticParams(), resolve the slug back to the original category name with getCategoryBySlug() in generateMetadata and the page body, and notFound() on unknown slugs. Remove any decodeURIComponent + capitalize CSS combo, the helper preserves the original casing.
  3. If your project ships a second locale via next-intl, set alternateLinks: false in defineRouting() unless every page has a locale-aware canonical AND every page is genuinely translated. If you keep alternateLinks: true, build a lib/locale-alternates.ts helper and route every generateMetadata through it so /de/about self-canonicals to /de/about instead of /about.
  4. Run npm run typecheck to confirm. Run a Semrush or Screaming Frog crawl two weeks after deploy to confirm the three warning categories drop to zero. Search Console recrawl latency for sitemap-flagged URLs is typically 7-14 days.

No breaking changes if your project was already naming routes without prefix collisions and never shipped a category with spaces or symbols. The fixes are defense-in-depth: cheap to apply, prevents a recovery cycle that costs weeks if the warnings get attached to a high-authority domain.

May 30, 2026·v1.8.6

Link-Safety Primitives, ExternalLink and UserLink, Plus MDX Anchor Hardening

securitynew

What This Defends Against

Every site that renders a user-submitted URL on a public page is a target for dofollow link farming. Drive-by SEO spammers script account creation across thousands of sites just to drop a homepage link in a profile bio, a testimonial form, a comment thread, or a "submit your project" entry. The link sits on a permanent, editorial-feeling page and (with default-dofollow rendering) passes link equity for as long as the page is indexed.

The two-layer reality in 2026:

  1. Google treats rel="nofollow" as a hint, not a directive since March 2020. Google may still crawl through it and pass small signal. The current recommended stack for UGC is rel="ugc nofollow noopener noreferrer" plus target="_blank", which is what WordPress 5.3 made the default for comment links and what Stack Overflow, Reddit, GitHub, and Medium all run by default on user-submitted URLs (Google: Qualify outbound links).
  2. SpamBrain can penalize the host site, not just the spammer. A SaaS that becomes a known link-farming target accumulates unnatural-link signals on its own domain. The fix is "secure by default" rendering: any user-input surface in the template renders external URLs through a primitive that auto-applies the full UGC rel stack, with the developer having to consciously opt into editorial dofollow.

What Ships in the Template

<ExternalLink> and <UserLink> primitives

Two new components in components/shared/ codify the split between editorial and user-submitted outbound links:

// components/shared/external-link.tsx
// Editorial outbound (you wrote the link, you vouch for it).
// Auto-applies target="_blank" and rel="noopener noreferrer".
// NO nofollow, editorial citations should pass equity.
<ExternalLink href="https://stripe.com/docs/payments">
  Stripe Payments docs
</ExternalLink>

// components/shared/user-link.tsx
// User-submitted outbound (UGC, you did not author it).
// Auto-applies target="_blank", rel="ugc nofollow noopener noreferrer",
// AND a scheme allowlist (http:, https:, mailto:) that rewrites other
// schemes to "#" to block javascript:, data:, vbscript: XSS payloads.
<UserLink href={user.websiteUrl}>{user.websiteUrl}</UserLink>

Both components accept any standard anchor props, dedupe rel tokens so user-supplied overrides compose cleanly, and default target to _blank (overridable). The split matches Google's outbound-link qualifiers exactly: noopener noreferrer is the security floor (blocks reverse-tabnabbing, strips Referer), ugc is Google's recommended attribute for user-generated content, and nofollow is stacked on top because the 2020 hint-vs-directive change made ugc alone an under-specified signal.

UserLink adds a second defense ExternalLink does not need: a URL scheme allowlist. noopener and noreferrer block reverse-tabnabbing and strip Referer, but they cannot block a <a href="javascript:fetch('/admin/wipe',{method:'POST'})"> that executes in the current document's origin. The same is true for data: and vbscript:. Editorial links don't need this check because the developer writes the href directly. User-submitted links do, because a malicious submitter can write any string into a profile-bio field and Reacts JSX will dutifully render it. Parsing the URL with new URL() and checking the resolved protocol against {http:, https:, mailto:} rewrites every other scheme to #.

Blog MDX renderer auto-rewrites external <a> tags

components/blog/mdx-content.tsx now ships a custom a component that detects external URLs (^https?://) and adds target="_blank" and rel="noopener noreferrer" automatically. Internal anchors and same-origin links are left alone so Next.js client-side navigation and in-page # anchors keep working untouched.

MDX is treated as editorial (the blog posts are authored in git, not user-submitted), so nofollow is NOT applied to MDX-rendered links, citation equity flows to the sources you cite. If guest-author MDX is ever accepted from outside contributors, the MDX surface should be flipped to UserLink semantics inside the a component.

Audit gaps closed on the live site

Two small pre-existing gaps in the SecureStartKit sales site got cleaned up in the same pass:

  • components/landing/Showcase.tsx had rel="noopener" but was missing noreferrer on the curated showcase entries. Tightened to rel="noopener noreferrer".
  • app/(marketing)/blog/[slug]/page.tsx rendered post.authorUrl as a raw <a> with no rel attribute at all. Now uses <ExternalLink>, which is the correct primitive (author bio links are editorial citations, not UGC).

CLAUDE.md "Link Safety Defaults" section

A new top-level section in CLAUDE.md documents the editorial-versus-UGC split, when to use which primitive, the "secure by default" rationale for UserLink, the trust-graduation pattern Google explicitly endorses ("you might remove this attribute from links posted by members or users who have consistently made high-quality contributions over time"), and an explicit warning against shipping open-redirect endpoints (PageRank passes ~100% through 30x redirects, so an unguarded /go?url= becomes a dofollow link laundry).

What Did NOT Change

The default behavior. With no UGC features wired up (the default state of the template), every Server Action and every rendered page behaves exactly as before. The new primitives sit in components/shared/ waiting to be imported. The MDX rewrite is invisible to existing posts that already used internal links or correctly attributed external citations.

The audit verdict on the live SecureStartKit site is that zero user-input surfaces existed before this release, so the live site was never at risk of dofollow link farming. The primitives ship anyway because the template IS the demo, and customers building UGC features on top of the template (comments, testimonials, profile pages, "built with" submission forms) need the correct primitives in place before they add the surface, not after.

Upgrading Existing Projects

  1. Copy components/shared/external-link.tsx and components/shared/user-link.tsx from the template into your project at the same paths.
  2. Find every existing <a href="https://..." target="_blank" ...> in components/landing/ and components/marketing/. Swap to <ExternalLink>. The component will auto-apply rel="noopener noreferrer", dedupe any rel tokens you pass, and preserve all other props (className, animations via wrapper components, etc.).
  3. If your project renders blog posts via MDX, copy the custom a component override from components/blog/mdx-content.tsx. Same logic in both projects.
  4. If your project ships any user-input surface (profile bio URL, testimonial submitter URL, comments, "submit your project"), render the link with <UserLink> not <a>. Do this BEFORE shipping the feature, retrofitting after the page has been crawled is much slower to recover from.
  5. (Optional) Audit your codebase for /go?url=, /redirect?to=, /out?url= style endpoints. If they exist and accept arbitrary destinations, allowlist destinations server-side or drop the endpoint, an open redirect passes PageRank and is a phishing vector at the same time.

No dependency changes, no lockfile changes, no breaking changes if you skip step 4. Run npm run typecheck to confirm.

May 26, 2026·v1.8.5

Vercel BotID Opt-In Scaffold for Auth and Billing Actions

securitynew

What This Defends Against

Rate limiting catches abuse from identified sources (one IP hammering login). It does not catch unidentified automation (distributed scrapers, headless browsers driven by Playwright or Puppeteer, AI bots rotating through residential proxy pools). The template's per-IP and per-user rate limits handle the brute-force case cleanly, but a coordinated bot that rotates through 200 IPs at one request each per minute defeats per-IP throttling without triggering any limit.

The upstream layer is client classification: deciding whether a request is automated BEFORE the Server Action runs its real logic. Vercel BotID is the platform-native option, an invisible CAPTCHA that runs in the browser and is validated server-side. The Basic tier is free on every Vercel plan. Deep Analysis (Kasada-powered ML detection of sophisticated bots) costs $1 per 1,000 checkBotId() calls on Pro and Enterprise.

The cost-attribution context the bot protection and DDoS mitigation pillar walks in full: Vercel Function Invocations bill regardless of response status. A Server Action that rejects a bot with { error: 'Too many attempts' } still bills the Invocation, the Active CPU consumed by the rejection path, and the Provisioned Memory the instance reserved. Rate limiting catches abuse; BotID is what stops the abuse from triggering the expensive code path in the first place.

What Ships in the Template

lib/botid.ts opt-in wrapper

A new module exports checkBotProtection(), a thin wrapper that short-circuits to { isBot: false } when the feature is disabled and dynamically imports botid/server when it is on. The wrapper handles the missing-package case gracefully so flipping the env var on and off does not require dependency changes:

export async function checkBotProtection(): Promise<{ isBot: boolean }> {
  if (process.env.NEXT_PUBLIC_BOTID_ENABLED !== 'true') {
    return { isBot: false }
  }
  try {
    const { checkBotId } = await import('botid/server')
    const result = await checkBotId()
    return { isBot: result.isBot }
  } catch {
    return { isBot: false }
  }
}

botid stays out of package.json by default. Customers who want it run npm i botid, set NEXT_PUBLIC_BOTID_ENABLED=true, follow Vercel's instrumentation-client.ts setup, and the wired Server Actions Just Work.

Wired into auth and billing Server Actions

Four routes get the upstream check, ordered above the existing rate limiter:

export async function login(data: LoginInput) {
  const { isBot } = await checkBotProtection()
  if (isBot) {
    return { error: 'Request blocked.' }
  }

  const ip = await getClientIp()
  const { success } = await rateLimit(`login:${ip}`, 5, 60)
  // ... existing logic
}

Same pattern in signup, resetPassword, and createCheckoutSession. The rate limiter still runs after BotID; the two layers compose, they do not substitute.

CLAUDE.md "Bot protection beyond rate limiting (opt-in)" section

A new deferred-decision section in CLAUDE.md documents the why-deferred reasoning, the cost-attribution reality the scaffold does NOT fully solve, the four-step enablement path, the routes worth protecting versus the routes where BotID is overkill, and the explicit honesty about the Cloudflare-in-front tradeoff. Mirrors the existing Hash-based CSP for marketing/blog/login routes (deferred) pattern.

SETUP.md WAF rule documentation

Phase 5: Deploy to Vercel gets a new Optional: bot protection at the edge (Vercel Pro) subsection documenting the highest-leverage post-deploy WAF rule: block AI scraper user-agents (Bytespider, GPTBot, ClaudeBot, CCBot, Amazonbot, Meta-ExternalAgent, PetalBot, GoogleOther, TikTokSpider, DuckAssistBot, anthropic-ai, OAI-SearchBot, PerplexityBot, Google-Extended, Google-CloudVertexBot, Applebot-Extended) on /blog/* and the public marketing surface. WAF blocking happens at Vercel's edge before any function runs, so it is the only layer that fully eliminates Invocation billing on rejected bot traffic.

What Did NOT Change

The default behavior. With NEXT_PUBLIC_BOTID_ENABLED unset, every Server Action behaves exactly as before: rate limiting on auth and billing, no extra dependency, no extra build step, no extra runtime cost. The opt-in surface is a single env var and one npm i.

The template still does not solve the Function Invocation cost on rejected bot requests; that requires edge-level blocking (Vercel WAF custom rules, documented in SETUP.md, or Cloudflare in front, which Vercel officially recommends against). The lib/botid.ts scaffold is the application-layer defense, not the cost-attribution defense.

Upgrading Existing Projects

  1. Copy lib/botid.ts from the template into your project at the same path.
  2. Add import { checkBotProtection } from '@/lib/botid' to actions/auth.ts and actions/billing.ts.
  3. Add the four-line const { isBot } = await checkBotProtection() block at the top of login, signup, resetPassword, and createCheckoutSession, above the existing rate limiter.

If you want to enable BotID:

  1. npm i botid
  2. Add NEXT_PUBLIC_BOTID_ENABLED=true to .env.local (Production scope on Vercel; not a secret, but Production-only is the right discipline).
  3. Add initBotId({ protect: [...] }) to instrumentation-client.ts per Vercel's setup guide. The protected paths must match the routes your forms submit to.
  4. (Optional) Enable Deep Analysis in Vercel dashboard → Firewall → Rules. Required for catching Playwright and Puppeteer; skip if your threat model is only low-sophistication automation.

No dependency changes, no lockfile changes, no breaking changes if you skip steps 4 through 7. Run npm run typecheck to confirm.

May 15, 2026·v1.8.4

Server-Side getUser() Migrated to Local JWT Validation

securitynew

What Changed

lib/supabase/server.ts:getUser() now uses supabase.auth.getClaims() instead of supabase.auth.getUser(). Every Server Action that identifies the caller (billing, admin, user settings, profile updates) now validates the JWT signature locally against Supabase's cached asymmetric public key set, instead of making a network round trip to the Supabase auth API.

The proxy-side rollout in v1.8.3 covered the route guard. This release extends the same pattern to the per-action identity check, which is where most of the cumulative latency lives. A typical login + dashboard load + a couple of settings clicks involves 5 to 10 Server Action calls. At 50 to 200ms per round trip depending on cross-region pairing, that's roughly 250ms to 2 seconds saved per user session.

What Ships in the Template

getUser() returns a normalized identity type

The return type changes from Supabase's full User object to a normalized AuthUser containing only what the template's callsites actually read:

export type AuthUser = {
  id: string
  email: string
}

export async function getUser(): Promise<AuthUser | null> {
  const supabase = await createServerClientWithCookies()
  const { data } = await supabase.auth.getClaims()
  const claims = data?.claims
  if (!claims || !claims.email) return null
  return {
    id: claims.sub,
    email: claims.email,
  }
}

The 5 callsites in the template (actions/admin.ts, actions/billing.ts (2 places), actions/user.ts, app/(dashboard)/billing/page.tsx) only ever read user.id and user.email, so no callsite changes were needed.

One downstream prop type changed: components/layout/dashboard-header.tsx previously typed its user prop as User from @supabase/supabase-js; it now types it as AuthUser from lib/supabase/server.

Breaking Change Note

If your project has customized Server Actions that read fields beyond user.id and user.email (for example user.user_metadata, user.app_metadata, user.created_at, user.phone), they will fail to compile after this change. Two paths to fix:

  1. Extend the AuthUser type in lib/supabase/server.ts and read the additional fields from claims in getUser(). Most JWT-resident fields (role, phone, app_metadata, user_metadata) are available on the claims object per the JwtPayload type in @supabase/auth-js.
  2. For fields not in the JWT claims (like created_at), pull them from the profiles table via getUserWithProfile() or a fresh admin client query.

Prerequisites

Asymmetric JWT signing keys must be enabled on the Supabase project. New projects ship with them by default since 2025; existing projects enable them in the Supabase dashboard under Authentication → Keys.

What Did NOT Change

  • getUserWithProfile() unchanged; it just calls the new getUser() and queries the profile row by user.id.
  • The cookie-based session client (createServerClientWithCookies) unchanged.
  • The admin client (createAdminClient) unchanged.
  • next.config.ts unchanged.
  • Marketing/blog/login routes still use the next.config.ts baseline CSP; the strict nonce-based CSP introduced in v1.8.3 still covers only /dashboard, /settings, /billing, /admin.

Upgrading Existing Projects

Replace the getUser() body in lib/supabase/server.ts with the getClaims() version above, add the AuthUser type export, and update any component that types its user prop as the Supabase User to use AuthUser instead. Run npm run typecheck to catch any callsite that reads fields beyond id and email; extend AuthUser for those.

May 15, 2026·v1.8.3

Nonce-Based CSP on Auth Routes and Local JWT Validation

securitynew

What This Defends Against

Two upgrades to the auth-gated dashboard area.

First, the dashboard, settings, billing, and admin routes previously relied on the same CSP as marketing pages: strict directives but with 'unsafe-inline' in script-src as a documented compromise for Next.js hydration scripts and shadcn/ui. 'unsafe-inline' is the canonical XSS escape hatch. An attacker who manages to inject a <script> tag into a Server Component's rendered output (via an unsanitized prop, a Markdown bypass, or React's raw-HTML escape hatch) gets script execution despite every other defense. The fix on routes that handle authenticated user data is nonce-based CSP with 'strict-dynamic'.

Second, the proxy was calling supabase.auth.getUser() on every protected-route request, which hits the Supabase auth API over the network. With asymmetric JWT signing keys (the default for Supabase projects since 2025), getClaims() verifies the JWT locally against the cached public key set instead. Faster, fewer cross-region round trips, and the Supabase-recommended path going forward.

What Ships in the Template

Scoped nonce-based CSP for dynamic auth routes

proxy.ts now generates a per-request nonce and emits a strict CSP for these route prefixes:

  • /dashboard
  • /settings
  • /billing
  • /admin

The CSP drops 'unsafe-inline' from script-src and adds 'nonce-${nonce}' 'strict-dynamic'. Modern browsers ignore the absence of 'unsafe-inline' once 'strict-dynamic' is present and only execute scripts that carry the matching per-request nonce. Next.js automatically stamps the nonce into its hydration scripts during render when the CSP header is set on the incoming request.

// proxy.ts (abbreviated)
const strictCspRoutePrefixes = [
  '/dashboard',
  '/settings',
  '/billing',
  '/admin',
]

const useStrictCsp = strictCspRoutePrefixes.some((p) => path.startsWith(p))
const nonce = useStrictCsp
  ? Buffer.from(crypto.randomUUID()).toString('base64')
  : null

Marketing pages, blog posts, login, signup, and reset-password stay on the next.config.ts CSP because they may be statically pre-rendered. Applying per-request nonces to those routes would break their inline hydration scripts (the build-time nonce wouldn't match the runtime CSP). The current next.config.ts CSP with 'unsafe-inline' is the safest baseline for SSG pages until a separate hash-based or full-dynamic migration ships.

style-src keeps 'unsafe-inline' on the strict routes too. shadcn/ui generates inline style="..." attributes; the accepted compromise is that inline styles cannot execute scripts, so the XSS attack surface from styles is much smaller than from inline <script> tags.

Local JWT validation via getClaims()

proxy.ts now uses supabase.auth.getClaims() instead of supabase.auth.getUser(). The new call validates the JWT signature against Supabase's cached asymmetric public key set, falling back to a network call only when the key set isn't cached. The admin email check reads claims.email (typed string | undefined per JwtPayload) with a defensive null guard.

const { data: claimsData } = await supabase.auth.getClaims()
const claims = claimsData?.claims ?? null
// ...
if (
  !appConfig.admin.enabled ||
  !claims.email ||
  !appConfig.admin.superAdminEmails.includes(claims.email)
) {
  return redirect('/dashboard')
}

Existing projects need asymmetric JWT signing keys enabled in the Supabase dashboard. New Supabase projects ship with them by default since 2025.

What Did NOT Change

  • The next.config.ts CSP that covers marketing, blog, login, signup, reset-password, and other static routes still uses 'unsafe-inline' in script-src. A full migration to nonce-based or hash-based CSP for those routes is a separate refactor (requires either making the pages dynamic, which costs SSG, or computing per-build hashes for every inline script).
  • Server-side helpers like lib/supabase/server.ts:getUser still use getUser(). Server Actions that call it continue to hit the network for validation. Migrating those is a separate change after this proxy-side rollout has soaked.

Upgrading Existing Projects

Copy the strictCspRoutePrefixes block, the buildStrictCsp helper, and the getClaims() swap from the template's proxy.ts into your project. Run npm run typecheck and npm run build to confirm. The proxy matcher does not change.

If you have additional auth-gated route prefixes beyond the template defaults, add them to strictCspRoutePrefixes. Anything you add must be a dynamic route (Server Component reading cookies, headers, or user data); applying nonce-based CSP to a statically rendered route will break its inline hydration scripts.

May 15, 2026·v1.8.2

Stricter CSP and Server Action CSRF for Proxy Deployments

securitynew

What This Defends Against

Two small holes the template's defaults left open.

First, the Content Security Policy shipped without object-src 'none' or upgrade-insecure-requests. The omission left legacy plugin content technically allowed and let HTTP subresources stay HTTP even on HTTPS pages. Neither is an exploitable vulnerability on its own, but both are explicit recommendations in the 3-layer injection defense guide.

Second, Server Action CSRF protection works automatically on Vercel because Vercel forwards X-Forwarded-Host correctly. Behind a custom proxy (Cloudflare Tunnel, a custom load balancer, a self-hosted reverse proxy), the Host header may not match the public origin, and Server Action POSTs return 403 in production. The fix is experimental.serverActions.allowedOrigins listing the trusted host.

What Ships in the Template

next.config.ts CSP additions

Two new directives in the Content-Security-Policy header:

key: 'Content-Security-Policy',
value: [
  // ... existing directives ...
  "object-src 'none'",
  'upgrade-insecure-requests',
].join('; ')

object-src 'none' blocks Flash and other legacy plugin content. Modern apps should never need it; the directive enforces that.

upgrade-insecure-requests forces every HTTP subresource (images, scripts, fonts) to upgrade to HTTPS. Belt-and-suspenders with HSTS, which the template already ships.

serverActions.allowedOrigins from NEXT_PUBLIC_APP_URL

A small getAppHost() helper parses NEXT_PUBLIC_APP_URL and supplies the host to experimental.serverActions.allowedOrigins. If the env is unset, the config is omitted entirely (no breakage on fresh clones).

function getAppHost(): string | null {
  const raw = process.env.NEXT_PUBLIC_APP_URL
  if (!raw) return null
  try {
    return new URL(raw).host
  } catch {
    return null
  }
}
const appHost = getAppHost()

const nextConfig: NextConfig = {
  ...(appHost
    ? {
        experimental: {
          serverActions: {
            allowedOrigins: [appHost, `*.${appHost}`],
          },
        },
      }
    : {}),
  // ... rest of config
}

Set NEXT_PUBLIC_APP_URL=https://yourdomain.com in .env.local (you already have this for the rest of the template) and the allowlist tracks it automatically.

What Did NOT Change

The CSP still uses 'unsafe-inline' in script-src for Next.js hydration scripts and shadcn/ui styles. The nonce-based migration that fully eliminates inline scripts is real refactor work and a separate release. The blog post walks the recommended endpoint at Next.js CSRF, XSS, SQLi: the 3-layer defense.

Upgrading Existing Projects

Copy the two CSP directives and the getAppHost() block into your project's next.config.ts. No dependency changes, no lockfile changes, no breaking changes. Run npm run typecheck to confirm.

May 12, 2026·v1.8.1

Supply-Chain Hardening: 7-Day Version Quarantine

securitynew

What This Defends Against

On May 11 the TanStack npm packages were compromised: 84 malicious versions across 42 @tanstack/* packages were published in a 6-minute window. The same playbook (the "Mini Shai-Hulud" worm) hit axios in March and Mistral, UiPath, and Guardrails packages in between. Each attack window is roughly 2 to 12 hours before npm yanks the bad versions.

Anyone who ran npm install against a ^ range during that window pulled poisoned code.

What Ships in the Template

A new .npmrc is now part of the template root with two settings:

min-release-age=7
save-exact=true

min-release-age=7

npm refuses to install any package version published within the last 7 days. If a malicious release lands and gets yanked within a week (which is what happens in practice), your install never sees it. The version stays invisible to you until it has survived a full week in the wild.

Requires npm 11.10 or later. Run npm install -g npm@latest if you are on an older release.

save-exact=true

When you run npm install <pkg>, the template writes "<pkg>": "1.2.3" to package.json instead of "<pkg>": "^1.2.3". This stops silent patch bumps to compromised versions and makes your package.json an honest record of what is actually installed.

Escape Hatches

If you genuinely need a fresh release for a one-off:

npm install some-package --min-release-age=0

Or temporarily edit .npmrc to set min-release-age=0 and revert when done.

What Changed

  • .npmrc added at template root (12 lines, mostly comments)
  • No code changes, no dependency changes, no lockfile changes
  • Existing projects built from earlier template versions: copy .npmrc into your repo root and upgrade npm

Why Project-Level, Not User-Level

You can also put these settings in ~/.npmrc to cover every project on your machine. The template ships them at the project level so:

  1. CI runners inherit the policy from the repo, not the developer machine
  2. Anyone cloning the template gets protected on first npm install
  3. The protection is auditable in version control
Apr 6, 2026·v1.8.0

Centralized Landing Content & CSP Dev Fix

improvedfixed

One File to Rebrand the Entire Landing Page

All landing page copy now lives in a single file: content/landing.ts. Hero taglines, feature descriptions, testimonials, FAQ items, CTA text, and footer links - everything a buyer needs to change before shipping is in one place.

Before

Copy was scattered across 7 component files (Hero.tsx, Features.tsx, Testimonials.tsx, Pricing.tsx, FAQ.tsx, CTA.tsx, Footer.tsx) in inline const blocks and hardcoded JSX strings. Rebranding meant hunting through every component.

After

Components are now pure presentation. They import from content/landing.ts and render whatever's there. To rebrand:

  1. Edit content/landing.ts (all marketing copy)
  2. Edit config.ts (app name, pricing plans, SEO defaults)
  3. Done. Zero component edits needed.

Type-safe Icon Picker

Feature icons are referenced by string key (e.g., icon: 'lock') instead of requiring lucide-react imports. A shared _icons.ts map ships with 20 common SaaS icons and provides autocomplete via the IconKey type. Lucide is tree-shaken, so unused icons cost zero bundle bytes.

Available icons: lock, credit-card, mail, database, shield, zap, sparkles, rocket, users, bar-chart, globe, bell, message-square, calendar, file-text, cloud, settings, heart, star, check-circle.

Self-Invalidating Demo Content

The placeholder copy is written to look professional but talks about the template itself ("This template saved me weeks of work", "Fork it, rebrand it, ship it"). This means:

  • First clone looks polished, not like a skeleton
  • Buyers can't accidentally ship placeholder copy because it obviously doesn't describe their product
  • A warning comment at the top of content/landing.ts reminds buyers to replace everything before production

CSP Fix for Development Mode

React 19's development build requires dynamic code execution for Fast Refresh and stack reconstruction, which the Content Security Policy header was blocking. The CSP now conditionally relaxes script-src only when NODE_ENV=development. Production CSP is unchanged and remains strict.

What Changed

  • content/landing.ts added (all landing copy, typed)
  • components/landing/_icons.ts added (20-icon map with IconKey type)
  • components/landing/Hero.tsx updated (reads from content)
  • components/landing/Features.tsx updated (reads from content, uses icon map)
  • components/landing/Testimonials.tsx updated (reads from content)
  • components/landing/Pricing.tsx updated (6 hardcoded strings extracted)
  • components/landing/FAQ.tsx updated (reads from content)
  • components/landing/CTA.tsx updated (reads from content)
  • components/landing/Footer.tsx updated (reads from content)
  • components/landing/faq-data.ts deleted (absorbed into content/landing.ts)
  • lib/seo.ts updated (FAQ import path)
  • next.config.ts updated (dev-only CSP relaxation)
Apr 2, 2026·v1.7.1

SEO Defaults & Audit Fixes

improved

Better SEO Out of the Box

Every new project built from the template now starts with production-grade SEO defaults. These fixes were identified during a full audit of securestartkit.com and ported back into the template.

New Files

  • app/icon.tsx - Dynamic favicon generated from your app name (first letter). No need to create image files manually.
  • app/logo.png/route.tsx - Dynamic logo image at /logo.png for Organization schema. Resolves the broken logo reference in structured data.
  • app/llms.txt/route.ts - AI crawler guidance file. Describes your product, lists key pages, and includes recent blog posts. Improves visibility in ChatGPT, Perplexity, and Google AI Overviews.

Meta Description Fixes

  • Privacy page - Added description (was missing entirely)
  • Terms page - Added description (was missing entirely)
  • Pricing page - Rewritten from generic "Simple, transparent pricing" to keyword-rich description
  • Blog page - Rewritten from generic "Read our latest articles and updates" to dynamic description using app name

Schema Fixes

  • Article author type - Changed from "@type": "Person" to "@type": "Organization" with URL. The previous setup incorrectly used Person type for an organization name.

Sitemap Improvements

  • Static page timestamps - Changed from new Date() (rebuild timestamp) to fixed dates. Google downgrades lastmod reliability when all pages share the same timestamp.
  • Category page timestamps - Now derived from the most recent post in each category instead of using the build timestamp.

RSS Autodiscovery

  • Added <link rel="alternate" type="application/rss+xml"> to root layout head. Feed readers can now automatically discover your blog's RSS feed.

What Changed

  • app/icon.tsx added (new file)
  • app/logo.png/route.tsx added (new file)
  • app/llms.txt/route.ts added (new file)
  • app/layout.tsx updated (RSS link)
  • app/sitemap.ts updated (lastmod logic)
  • lib/seo.ts updated (author type)
  • Meta descriptions added/improved on 4 pages
Mar 31, 2026·v1.7.0

Major Dependency Upgrade

improved

All Dependencies Updated to Latest Stable

Every core dependency has been upgraded to its latest stable version. The template now ships with the most modern stack available.

Framework & Runtime

  • Next.js 15 → 16.2 — proxy.ts replaces middleware.ts (runs on Node.js runtime, not Edge), Turbopack is the default bundler
  • React 19.0 → 19.2 — latest stable with performance improvements
  • TypeScript 5.7 — unchanged, already latest

Styling

  • Tailwind CSS 3.4 → 4.2 — CSS-first configuration via @theme directive, tailwind.config.ts removed, autoprefixer built-in
  • Framer Motion 11 → Motion 12 — package renamed from framer-motion to motion, imports changed to motion/react

Validation

  • Zod 3.24 → 4.3 — .error.errors renamed to .error.issues throughout the codebase

Auth & Database

  • @supabase/supabase-js 2.47 → 2.101 — latest stable
  • @supabase/ssr 0.5 → 0.10 — latest stable

Payments

  • Stripe 20 → 21 — API version updated to 2026-03-25.dahlia
  • @stripe/stripe-js 5 → 9 — latest client-side SDK

Content & Email

  • Resend 4 → 6.10 — latest email delivery SDK
  • @react-email/components 0.0.31 → 1.0.10 — hit v1.0 stable
  • Lucide React 0.469 → 1.7 — latest icons

i18n

  • next-intl 3.25 → 4.8 — config moved from i18n.ts to i18n/request.ts

What Changed

  • middleware.ts renamed to proxy.ts (Next.js 16 convention)
  • tailwind.config.ts removed — theme config now lives in globals.css via @theme directive
  • postcss.config.mjs updated to use @tailwindcss/postcss
  • All framer-motion imports changed to motion/react
  • All .error.errors changed to .error.issues (Zod 4)
  • Stripe API version updated to 2026-03-25.dahlia
  • i18n.ts moved to i18n/request.ts (next-intl 4 convention)
  • Fixed typo in breadcrumbs.tsx (aimport → import)

Migration Guide (for existing users)

If you're upgrading from v1.6.x:

  1. Rename middleware.ts → proxy.ts and rename the exported function from middleware to proxy
  2. Delete tailwind.config.ts — theme config is now in globals.css under @theme {}
  3. Update postcss.config.mjs to use @tailwindcss/postcss instead of tailwindcss + autoprefixer
  4. Find-and-replace from 'framer-motion' → from 'motion/react'
  5. Find-and-replace .error.errors → .error.issues (Zod validation)
  6. Move i18n.ts → i18n/request.ts
  7. Update Stripe apiVersion to '2026-03-25.dahlia' in lib/stripe/client.ts
  8. Run npm install to pull the updated package.json
Mar 23, 2026·v1.6.1

Rate Limiting Security Fix

fixed

Rate Limiting Now Uses Per-Caller Keys

Auth and billing Server Actions previously used shared global keys for rate limiting. This meant one user hitting the limit would block all other users from logging in or signing up during that window — an accidental self-inflicted lockout.

Rate limits now key by the caller's identity:

  • Login, signup, password reset — keyed by IP address. Each visitor gets their own counter. One attacker hitting the limit does not affect anyone else.
  • Checkout, billing portal — keyed by user ID. Authenticated actions use a stable, non-spoofable identifier instead of IP.

Billing actions (createCheckoutSession, createPortalSession) also had no rate limiting at all. Both are now protected.

What Changed

  • actions/auth.ts — login, signup, resetPassword now use login:${ip}, signup:${ip}, reset:${ip} keys
  • actions/billing.ts — added rate limiting to createCheckoutSession (10/min per user) and createPortalSession (10/min per user)
Mar 19, 2026·v1.6.0

Improved Blog Skill

improved

/write-blog Skill Upgraded to v3.2.0

The blog writing skill received several improvements:

  • Fact verification — all statistics must be verified by fetching the actual source page before citing
  • Natural writing rhythm — varies sentence length, uses contractions, breaks parallel structure to avoid AI-sounding output
  • Post-save verification — automatically checks for em-dashes, calculates read time, validates excerpt length, and verifies internal links
  • Post-publish reminders — conditionally reminds about sitemap, RSS, and structured data updates

What Changed

  • Upgraded /write-blog skill from v3.0.0 to v3.2.0
Mar 14, 2026·v1.5.0

Claude Code Skills - AI-Powered Growth Commands

newimproved

Claude Code Skills

SecureStartKit now ships with 4 built-in Claude Code skills - slash commands that automate content creation, SEO, and brand strategy directly from your terminal.

New Skills

/write-blog - AI Blog Writer

Research keywords by search volume and difficulty, then generate full SEO-optimized blog posts with proper frontmatter, internal links, JSON-LD, and citations. Picks the best keyword opportunity or writes on a topic you choose.

/comparison-page - Competitor Comparison Pages

Build high-converting "[Product] vs [Competitor]" pages. Researches the competitor, generates feature comparisons, and creates SEO-optimized pages with structured data.

/brand-frame - Brand Strategy Framework

Define your brand's strategic frame using the Controlled Exposure Formula. Audits existing pages for brand consistency and outputs a comprehensive brand strategy document.

/weekly-free-tool - Free Tool Page Builder

Find keyword opportunities, research existing tools in your project, and build new free tool pages targeting high-value keywords. Includes JSON-LD, FAQ sections, and "How to use" guides.

How It Works

Skills live in .claude/skills/ and are available as slash commands in Claude Code. Type /write-blog in your terminal and Claude handles the rest - keyword research, content generation, proper formatting, SEO optimization, and file creation.

What Changed

  • Added .claude/skills/write-blog/SKILL.md
  • Added .claude/skills/brand-frame/SKILL.md
  • Added .claude/skills/comparison-page/SKILL.md
  • Added .claude/skills/weekly-free-tool/SKILL.md
  • Upgraded write-blog skill with improved keyword research and SEO scoring
Mar 13, 2026·v1.4.0

GEO optimization for AI search citations

feature

GEO: AI citation optimization

Every project built on SecureStartKit is now optimized for Generative Engine Optimization (GEO), so your public pages get cited by AI search engines like ChatGPT, Claude, Perplexity, and Gemini.

  • toolPageJsonLd() helper in lib/seo.ts generates WebApplication + BreadcrumbList + FAQPage structured data for free tool pages in a single call
  • GEO rules in CLAUDE.md ensure every new page you build follows AI citation best practices: JSON-LD, H1/H2 heading structure, FAQ sections with self-contained answers, and "last updated" timestamps
  • Blog GEO rules added to the /write-blog skill: question-based titles, citable data points, comprehensive coverage, and clear definitions
Mar 13, 2025·v1.4.0

Stripe SDK v20 + Clover API

breakingimproved

Stripe SDK upgraded to v20

Upgraded from Stripe Node SDK v17 to v20 with the 2026-02-25.clover API version. This is the latest major Stripe API version and includes breaking changes to the webhook payload structure.

Breaking: Subscription period fields moved

In the Stripe clover API version, current_period_start and current_period_end moved from the top-level Subscription object to each SubscriptionItem.

Before (acacia API):

const periodStart = subscription.current_period_start
const periodEnd = subscription.current_period_end

After (clover API):

const item = subscription.items.data[0]
const periodStart = item.current_period_start
const periodEnd = item.current_period_end

The webhook handler has been updated to read from the correct location.

Breaking: Invoice subscription field moved

invoice.subscription no longer exists. Use invoice.parent.subscription_details.subscription instead.

What Changed

  • Upgraded stripe package from ^17.0.0 to ^20.0.0
  • Updated lib/stripe/client.ts API version to 2026-02-25.clover
  • Updated app/api/webhooks/stripe/route.ts to read period fields from SubscriptionItem
Mar 9, 2025·v1.3.0

Smarter Blog Generation

improved

Source Enrichment for Blog Posts

The blog generation pipeline now deep-reads cited source URLs before passing research to Gemini. This means Gemini sees the actual article content instead of relying on Perplexity's summary alone, producing posts with verified data points instead of fabricated statistics.

  • Source enrichment step fetches up to 5 source URLs in parallel, extracts real page titles and body text
  • Real citation titles instead of generic "Source from domain.com"
  • Source data passed to Gemini as ground truth for facts and statistics

Fact Verification Rule

Added a critical instruction to the Gemini prompt preventing it from inventing plausible-sounding statistics. Every number, percentage, or data point must now come directly from the provided research or source data.

Lower Temperature

Gemini temperature lowered from 0.7 to 0.3 for blog generation. Lower temperature keeps the model closer to provided source material, reducing hallucination in factual content.

Gemini Model Upgrade

Upgraded the blog generation model from gemini-2.0-flash to gemini-3-pro-preview for higher quality output.

What Changed

  • Added scripts/blog/source-enrichment-service.ts (new file)
  • Updated scripts/blog/perplexity-service.ts to return citation URLs
  • Updated scripts/blog/generate-post.ts with Step 1.5 source enrichment
  • Updated scripts/blog/gemini-service.ts with lower temperature, source data input, and fact verification rule
Feb 25, 2025·v1.2.0

Security Headers

improved

Security Headers Out of the Box

Added security headers to next.config.ts applied to all routes:

  • X-Frame-Options: DENY - Prevents clickjacking by blocking iframe embedding
  • X-Content-Type-Options: nosniff - Stops browsers from MIME-type sniffing
  • Referrer-Policy: strict-origin-when-cross-origin - Controls referrer information leakage
  • X-DNS-Prefetch-Control: on - Enables DNS prefetching for external links
  • Strict-Transport-Security - Forces HTTPS with 2-year max-age, subdomains, and preload
Feb 24, 2025·v1.1.0

SEO Structured Data & Canonical URLs

improved

Canonical URLs on Every Page

All 17 pages now include explicit alternates.canonical in their metadata. This prevents search engines from indexing duplicate URLs caused by trailing slashes, query parameters, or UTM tracking tags.

Product Structured Data (SoftwareApplication)

The landing page and pricing page now inject SoftwareApplication JSON-LD with AggregateOffer, showing your price range directly from your billing config. This can trigger rich results in Google displaying your pricing in search.

FAQ Rich Results

Added FAQPage JSON-LD schema on the landing page and pricing page. Google can now display your FAQ questions and answers directly in search results, expanding your listing's real estate. FAQ content is extracted to a shared data file so the UI and structured data stay in sync automatically.

Breadcrumb Schema for Blog

Blog posts and category pages now include BreadcrumbList JSON-LD. Google will display clean breadcrumb trails (Home > Blog > Post Title) instead of raw URLs in search results.

What Changed

  • Added alternates.canonical to all page metadata exports
  • Injected productJsonLd() on / and /pricing
  • Removed fabricated review from product schema (Google penalizes fake reviews)
  • Added faqJsonLd() on / and /pricing
  • Extracted FAQ data to components/landing/faq-data.ts for shared use
  • Added breadcrumbJsonLd() helper to lib/seo.ts
  • Injected breadcrumb schema on /blog/[slug] and /blog/category/[category]
Feb 20, 2025·v1.0.0

SecureStartKit Public Launch

new

SecureStartKit is Live

We're excited to launch SecureStartKit - a production-ready Next.js SaaS template built with security as the foundation.

What's Included

  • Security-first architecture - Backend-only data access, Zod validation, RLS-enabled database
  • Next.js 15 - App Router, Server Components, Server Actions
  • Supabase - Postgres database, authentication, type-safe queries
  • Stripe - Subscriptions and one-time payments with webhook handling
  • React Email + Resend - Beautiful transactional emails
  • Landing page - 7 customizable sections (Hero, Features, Testimonials, Pricing, FAQ, CTA, Footer)
  • Blog & Docs - MDX-powered content with categories, RSS, and sidebar navigation
  • Admin panel - User management and purchase tracking
  • Dark mode - System-aware theme with toggle
  • SEO - Sitemap, Open Graph images, structured data, RSS feed
  • i18n ready - Optional internationalization support

Pricing

  • Starter - $199 (one-time)
  • Pro - $299 (one-time, includes admin panel, email templates, i18n, priority support)

Both tiers include lifetime updates and full source code access.

Jan 15, 2024·v1.0.0

Initial Release

new

New Features

  • Authentication - Email/password and Google OAuth
  • Stripe Integration - Subscriptions and one-time payments
  • Dashboard - User overview, settings, and billing
  • Blog System - MDX-based blog with categories and RSS
  • Email Templates - Welcome, verification, and password reset
  • Admin Panel - User management and subscription overview
  • Landing Page - Hero, features, testimonials, pricing, FAQ sections
  • Dark Mode - Light and dark theme with system preference detection