Skip to content
ErmisAI

Public site, SEO and marketing routes

The marketing route surface, structured data, OG images, and the rules that keep public claims accurate.

The public site is the src/app/[locale]/ surface: 14 content pages, every one of them anonymous, URL-localised into ten locales, and served from Vercel's edge cache. It shares the root layout with the tenant workspace and the admin console but nothing else — no auth() call, no tenant data, no cookies that survive the cache.

Almost all of it is static i18n copy plus hardcoded arrays in components. /pricing is the single exception: it reads the live billing catalog.

The route surface

Fourteen content pages under src/app/[locale]/, plus the three (auth) routes. Locale prefix is mandatory — routing.localePrefix = 'always' (src/i18n/routing.ts:9), so https://ermisai.com/pricing is redirected to /en/pricing by the middleware.

PathWhere it is linkedSitemap priority
/Wordmark1.0
/pricingTop nav, footer Product0.9
/lyraTop nav, footer Product0.85
/partnersTop nav, footer Product0.8
/aboutTop nav, footer Company0.8
/contactTop nav, footer Product0.7
/securityFooter Product only0.7
/careersFooter Company only0.6
/privacyFooter Legal0.4
/cookiesFooter Legal0.3
/gdprFooter Legal0.3
/ai-policyFooter Legal0.3
/termsFooter Legal0.3
/subprocessorsFooter Legalnot in the sitemap

Top nav is exactly five links in this order: Pricing, Lyra, Partners, About, Contact (messages/en.json nav.links). /security and /careers are footer-only.

The ten locales are en, el, pl, it, es, pt, sv, da, nb, fi, default en (src/i18n/routing.ts:6).

/subprocessors ships, is footer-linked, and is allowlisted in the proxy — but it is absent from src/app/sitemap.ts:6-19, which lists 13 pathnames. That looks like an oversight rather than a decision.

Adding a public page

A new marketing page needs four edits, not one. Miss the proxy entry and anonymous visitors are redirected to /en/sign-in instead of seeing the page.

Create the route

src/app/[locale]/<slug>/page.tsx. Export generateMetadata calling buildPageMetadata, call setRequestLocale(locale) in the page body, and render <MarketingPageJsonLd /> at the top of <main>.

Add it to isLocalizedPublicRoute

src/proxy.ts:27-49. This is the anonymous allowlist. Without an entry the request falls through the middleware branch order to auth.protect() (src/proxy.ts:331-333).

Add it to isCacheableMarketingRoute

src/proxy.ts:54-69. Same list minus the auth routes. Skipping this leaves the page working but uncached at the edge.

Add it to publicPages in src/app/sitemap.ts

With a changeFrequency and a priority. Nothing generates this list from the filesystem.

Two follow-ups depending on what the page contains:

  • Client components that call useTranslations('X') need 'X' in PUBLIC_CLIENT_NAMESPACES (src/lib/i18n/client-messages.ts:40-45, currently common, nav, footer, auth, cookieConsent, home, pricing, animations). The public NextIntlClientProvider is handed only those namespaces, so a missing one renders raw message keys. tests/client-message-scoping.vitest.ts walks the real import graph of each route segment and fails CI with the exact gap.
  • New keys in messages/en.json must reach all ten locale files or tests/locale-parity.vitest.ts fails CI. See Localization system.

Rendering, caching and the stable nonce

Every page renders dynamically: the root layout reads headers() for the CSP nonce (src/app/layout.tsx:63), which opts the route out of static rendering. generateStaticParams on the [locale] layout (src/app/[locale]/layout.tsx:11-13) declares the ten locale params but does not make the pages static.

The edge cache compensates. applyMarketingEdgeCache (src/proxy.ts:210-215) sets both Vercel-CDN-Cache-Control and CDN-Cache-Control to public, s-maxage=300, stale-while-revalidate=86400 and deletes Set-Cookie, because Vercel refuses to cache a cookie-bearing response and both Clerk and next-intl attach cookies an anonymous page does not need.

Because the cached HTML must stay byte-identical, cacheable marketing routes use a deployment-stable CSP nonce — base64("ermis-marketing-" + VERCEL_DEPLOYMENT_ID) (src/proxy.ts:89-91) — instead of a per-request one. It rotates on every deploy.

Per-visitor personalisation on these 14 pages is impossible by construction. Any cookie you set is stripped, and the HTML is shared for five minutes across all visitors of a deployment. Signed-in users never see the landing at all: / and /{locale} redirect to /app in middleware (src/proxy.ts:293-299), which is why the landing page contains no auth() call.

Full middleware ordering, the CSP directives and the auth-gate convention are on Route surfaces, proxy and auth gates.

Metadata

src/lib/marketing/metadata.ts is the single builder. Every one of the 14 pages calls buildPageMetadata from its generateMetadata.

buildPageMetadata({ locale, title, description, pathname }) emits:

  • alternates.canonical — the locale-prefixed absolute URL.
  • alternates.languages — one entry per locale plus x-default. Keys are BCP-47 tags from resolveIntlLocale (src/lib/i18n/config.ts:111-134), so the hreflang set is en-US, el-GR, pl-PL, it-IT, es-ES, pt-PT, sv-SE, da-DK, nb-NO, fi-FI plus x-default pointing at the en URL.
  • openGraphtype: 'website' by default, per-locale locale, siteName: 'ErmisAI', the canonical URL, and the social image.
  • twittercard: 'summary_large_image' with the same image.

The title template lives in buildRootMetadata (metadata.ts:136-139): %s | ErmisAI. buildFullPageTitle skips the suffix when the title already equals the site name.

resolveSiteUrl() (metadata.ts:71-77) resolves in order: NEXT_PUBLIC_SITE_URLVERCEL_PROJECT_PRODUCTION_URLVERCEL_URL.

If none of the three resolve, buildAbsoluteSiteUrl returns null and every canonical, hreflang, OG and JSON-LD URL is silently omitted. The page still renders. Nothing fails loudly. Check this first when a preview deployment ships metadata with no URLs.

buildNoIndexMetadata (metadata.ts:200-207) is the opposite helper — index: false, follow: false plus noarchive, noimageindex, nosnippet for googleBot. Every /app and /admin page uses it.

Social share images

There is one image route: src/app/[locale]/opengraph-image.tsx. It renders a 1200×630 PNG through next/og — surface #161616, brand #d69a4f, a wordmark block, common.siteDescription in the page locale, and the site host from siteConfig.siteUrl. @vercel/og ships a default font, so the route makes no external font fetch.

buildSocialImages (metadata.ts:49-57) points OG and Twitter tags at /{locale}/opengraph-image explicitly:

/**
 * Points OG/Twitter image tags at the per-locale `opengraph-image` route (rendered by next/og). Set
 * explicitly rather than relying on the file convention's auto-injection: that only attaches to the
 * `[locale]` index, not to child routes whose `generateMetadata` supplies their own `openGraph`.
 */

That is the trap: the Next.js file convention does not inherit to child routes that define their own openGraph block. Every page here does, so without the explicit reference only the landing page would carry a card.

The image route is allowlisted anonymously in the proxy (src/proxy.ts:44-45) — social and AI scrapers carry no session, and a redirect to sign-in breaks every card.

There is no twitter-image file anywhere under src/app. The proxy allowlists twitter-image(.*) pre-emptively; Twitter cards use the same opengraph-image URL.

Structured data

src/lib/marketing/structured-data.ts holds five builders, rendered through StructuredDataScript (src/components/seo/StructuredDataScript.tsx), which JSON-stringifies the node and unicode-escapes every < character before injecting it.

BuilderEmitted on
buildOrganizationJsonLd/ only
buildWebsiteJsonLd/ only
buildSoftwareApplicationJsonLd/ and /pricing
buildWebPageJsonLdevery content page
buildBreadcrumbJsonLdevery content page except /

Twelve of the 14 pages emit the WebPage + BreadcrumbList pair through the one-line <MarketingPageJsonLd /> wrapper (src/components/seo/MarketingPageJsonLd.tsx). The landing page and /pricing assemble their arrays by hand because they add extra nodes.

The @id discipline. organizationId() returns {siteUrl}/#organization. WebSite publisher and SoftwareApplication provider both reference that @id rather than inlining a second Organization node, so Google resolves one entity instead of duplicate anonymous ones.

sameAs cannot drift. Organization.sameAs maps socialProfiles (src/lib/marketing/site-config.ts:31-44) — the same array the footer renders. Changing one changes both.

The offer range is derived, not written. buildSoftwareApplicationJsonLd filters plan.internalOnly !== true, keeps non-null priceMonthly values, and builds an AggregateOffer (structured-data.ts:94-108). With the current catalog that is prices 0, 20, 80, 79, 239lowPrice "0", highPrice "239", offerCount 5, priceCurrency from siteConfig.currency (EUR). The hidden internal_unlimited plan is excluded by the same filter that keeps it off /pricing.

tests/marketing/structured-data.vitest.ts pins all of it: the Organization @id, the WebSite publisher-by-@id reference, the AggregateOffer derivation, and an assertion that the public offer count is strictly below the priced-plan total — so an internal plan leaking into the advertised range fails CI.

FAQPage JSON-LD is deliberately absent from /pricing, with the reason in-code (src/app/[locale]/pricing/page.tsx:68-69): Google retired the FAQ rich result on 2026-05-07. The visible Q&A stays for readers and AI extraction. Do not re-add the markup.

robots.txt

src/app/robots.ts. One shared disallow list — /admin, /api, /app, /auth, /*/sign-in, /*/sign-up — applied to * and then repeated verbatim for eight named AI agents:

const ALLOWED_AI_AGENTS = [
  // Search / answer indexers (AI-citation visibility)
  'OAI-SearchBot',
  'Claude-SearchBot',
  'PerplexityBot',
  // Training crawlers (allowed)
  'GPTBot',
  'ClaudeBot',
  'CCBot',
  'Google-Extended',
  'Meta-ExternalAgent',
]

Training crawlers are allowed by an explicit decision recorded in the file (2026-07-09). The per-agent rules mirror the wildcard rule so the intent survives any future tightening of *.

The wildcards in /*/sign-in and /*/sign-up exist because auth routes are locale-prefixed for all ten locales; enumerating them would mean 20 lines.

Sitemap and manifest

src/app/sitemap.ts produces 13 pathnames × 10 locales = 130 entries, each with alternates.languages from buildAlternateLanguages. An entry is skipped entirely when its URL cannot be resolved, so an unset site URL yields an empty sitemap rather than a broken one.

src/app/manifest.ts is the PWA manifest: name and short name from siteConfig, description from common.siteDescription in the default locale, display: 'browser', background_color: '#fcfcfc', theme_color: '#161616', four icon entries. Matching viewport exports live in src/app/layout.tsx:50-56 (colorScheme: 'light dark' plus light and dark themeColor).

/pricing is the only page reading product data

The data path is src/lib/billing/catalog.tsbuildPublicPricingTiers() in src/lib/marketing/site-content.ts<PricingSection>.

PricingSection is a client component ('use client', src/components/marketing/PricingSection.tsx:1), so the billing catalog module is bundled into the browser payload for that route.

Things worth knowing before you change anything on this page:

  • Prices, envelopes, seats, caps and capabilities are code constants. There is no admin UI and no env var for public pricing. A price change is a code change, a deploy, and a matching Polar product.
  • Half the page is not localised. tier.label, tier.description, plan.label, plan.name, plan.audience, plan.summary and every plan.highlights bullet are English constants in catalog.ts, passed through verbatim (site-content.ts:114-140). A visitor on /el/pricing sees English plan names, audiences, summaries and bullets. Only prices, billing notes, limit labels, feature names, CTA labels, "Yes"/"-", "Unlimited"/"Custom" and the surrounding hero/FAQ/notes copy come from messages/*.json.
  • Currency notation is mixed on the same card. formatPriceLabel renders `${priceMonthly} EUR` (site-content.ts:59) while formatEnvelopeLabel renders €${cents/100} (site-content.ts:87). So a card reads "20 EUR" for the price and "€20" for the capacity.
  • Business plans silently drop their source cap. When includedSeats is non-null, the limit rows become [Seats included, Monthly AI capacity, Monitoring rules] (site-content.ts:106-112). Business Plus and Business Pro do have source caps in the catalog; the number appears nowhere on /pricing, including the comparison table, whose limit rows are the union of the active tier's card limits.
  • One capability never renders. BillingPlanCapabilities has eight keys; buildComparisonRows renders seven (PricingSection.tsx:56-72). managedIntegrations is real and enforced in-app but invisible on the public table.
  • The "Recommended" badge is positional, not a catalog field: activeTier.id !== 'enterprise' && index === plans.length - 1 (PricingSection.tsx:266). Reorder a tier's plans and the badge moves.
  • No CTA on this page reaches checkout. checkoutMode === 'contact' sends the visitor to /contact; everything else sends them to /sign-up with no plan carried (site-content.ts:127-139). Checkout happens after sign-in, from Billing.

Rules that keep public claims accurate

The public surface makes claims that the product has to keep. These are the ones that break quietly.

Plan capability claims must match catalog.ts. WordPress delivery and API keys are Individual Pro, Business Pro and Enterprise. Webhook delivery is every paid plan. Team management and shared workspaces are Business and Enterprise. Dedicated support and custom integrations are Enterprise only. The landing bento card, /about step 04, the /careers build list and the delivery animation all repeat this in prose; the comparison table derives it from the catalog. Only one of those updates itself.

Marketing role vocabulary is not the permission model. The landing metrics tile says "4 / Editorial roles / EiC · ME · Desk · Reporter" and a bento card lists five names including "Viewer". The tenant role enum has exactly three values — owner, admin, member (src/lib/auth/newsroom-roles.ts:3). The newsroom titles are free-text display strings suggested in the team UI and used in no access check. Do not let marketing copy leak into product documentation as roles.

"1,400+" is catalog size. news-sources/catalog.v1.json ships 1,427 entries. In that seed file 1,293 are active: true and 52 have ingestEnabled: true. The number describes how many sources the catalog contains, not how many feeds are being polled.

Fabricated sample content must stay labelled. The landing "Live feed" section renders five hardcoded story cards and carries the footnote "Simulated feed - illustrative of real intake behaviour" (messages/en.json home.liveFeed.footnote). The "Live Sources" ticker is 15 hardcoded outlet names; the logo loop is a curated display set. The /lyra specimen draft and wire log are hardcoded fiction — the wire log's "87 countries" and "1.402 feeds" do not correspond to the catalog, which has 57 distinct region values. None of this is fed by the product, and the labels are the only thing keeping it honest.

Security-page disclaimers are load-bearing. /security states plainly that there is no SOC 2 or ISO 27001, no public bug bounty, and no uptime SLA on Free, Plus or Pro. Those sentences are the accurate position. See Security controls and Data residency and subprocessors.

Some marketing headings are hardcoded English on purpose or by accident. EditorialPrinciples.tsx:10-39 hardcodes "EDITORIAL ASSIST", "SOURCE-GROUNDED OUTPUT", "WORKFLOW, NOT RIGHTS" and "SIGNAL CLARITY"; only the numbers and descriptions come from messages. The /about equivalents are localised. Lyra's ten-language greeting wall and the wire log are deliberately untranslated, documented in-code (lyra/page.tsx:20-22, 68-69).

Site configuration

src/lib/marketing/site-config.ts captures every value at module load, so all of these need a rebuild and redeploy to change.

Env varDefault in codeEffect
NEXT_PUBLIC_SITE_URLnone (falls back to VERCEL_PROJECT_PRODUCTION_URL, then VERCEL_URL)Canonical URLs, hreflang, sitemap, robots host, JSON-LD, OG image URLs
NEXT_PUBLIC_ERMIS_LOCATION'Greece'Organization.areaServed
NEXT_PUBLIC_ERMIS_LEGAL_NAMEnullOrganization.legalName; interpolated into Terms and Privacy body copy via src/lib/marketing/legal.ts:3
NEXT_PUBLIC_ERMIS_CURRENCY'EUR'AggregateOffer.priceCurrency only — it does not change the "EUR" and "€" strings on /pricing, which are hardcoded in site-content.ts
NEXT_PUBLIC_ERMIS_X_URLhttps://x.com/ermis-ainewsroomFooter "X" link and Organization.sameAs
NEXT_PUBLIC_ERMIS_LINKEDIN_URLhttps://linkedin.com/company/ermisai-newsroomFooter "LinkedIn" link and Organization.sameAs
NEXT_PUBLIC_ERMIS_CONTACT_EMAILnull/contact General row, Organization.email and contactPoint
NEXT_PUBLIC_ERMIS_SALES_EMAILnull/contact Sales row
NEXT_PUBLIC_ERMIS_PARTNERSHIPS_EMAILnull/contact Partnerships row, /partners CTA
NEXT_PUBLIC_ERMIS_PRIVACY_EMAILnull/contact Privacy & GDPR row, legal pages
NEXT_PUBLIC_ERMIS_SECURITY_EMAILnull/contact Security row, /security reporting strip
NEXT_PUBLIC_ERMIS_CAREERS_EMAILnull/contact Careers row, /careers contact strip

getBestAvailableEmail() falls back general → sales → partnerships → privacy → security → careers for the JSON-LD contact point.

/contact has no form — it is a table of email addresses. When a NEXT_PUBLIC_ERMIS_*_EMAIL var is unset, that row degrades to a muted fallback sentence and there is no way to reach that channel at all. /security degrades to the literal string "No security inbox published yet." and /careers to "No careers inbox published yet."

Three of these keys are checked by collectLaunchConfigStatus() under the legal category and reported by GET /api/healthconfig.missing: NEXT_PUBLIC_ERMIS_LEGAL_NAME, NEXT_PUBLIC_ERMIS_PRIVACY_EMAIL, NEXT_PUBLIC_ERMIS_CONTACT_EMAIL. That endpoint is the authoritative answer to "is this actually set in production" — .env.production is not read by Vercel.

What does not exist

  • No DPA page or route. Public legal copy says the DPA is available before contract; there is no artifact, download link, or request form in the repo. /subprocessors is the declared public source of truth for sub-processor names.
  • No press page, demo-request flow or acceptable-use page.
  • No FAQ rich-result markup, removed deliberately.
  • No security.txt. The only .well-known route is src/app/.well-known/vercel/flags/route.ts.
  • /careers lists no roles. The H1 is "No open roles right now." and the role-area list is explicitly labelled as not job listings.

On this page