Marketing & SEO
Assemble the prerendered landing surface and ship structured data, sitemaps, and robots from config.
The marketing surface is driven by @repo/config - the config.seo block, the config.indexable master switch, and config.business / config.social for structured data. SEO output (Organization/WebSite/FAQPage JSON-LD, sitemap, robots) is emitted from config keys, so config is the single source of truth.
What you get
- A prerendered (static) landing page assembled from section components in
apps/web-next/components/landing/. - Visible section copy from i18n
landing.*keys (packages/i18n/translations/en/web.json); the landing<title>/ meta description fromhome.meta- not config. - Graph-linked JSON-LD -
Organization+WebSite, a per-pageWebPage, a blog-postArticlegraph, and aSoftwareApplicationwith review stars - plus config-driven sitemap + robots files. - Flag-aware sections: footer blog links gate on
config.blog.enabled; pricing CTAs go inert whenconfig.payment.enabledisfalse. - A single shared data export,
socialProofAvatars- five hero avatars to replace on rebrand.
Landing & marketing pages
The landing page (app/[locale]/(marketing)/page.tsx) composes shared sections in this order: Hero, StatsRow, DemoVideo, Showcase, FeaturesListicle, HowItWorks, ProblemSolution, TestimonialsGrid, Pricing, PricingComparison, FAQ, FinalCta, plus a FaqJsonLd node.
Most sections are server components reading copy via getTranslations() (next-intl/server). Three are "use client" and use useTranslations(): pricing, pricing-comparison, and features-listicle - all three are lazy-hydrated via next/dynamic (server-rendered HTML, code-split hydration).
Marketing routes are prerendered - the layout (app/[locale]/(marketing)/layout.tsx) stays static by calling setRequestLocale and reading no dynamic request data.
| Route | Purpose |
|---|---|
/ | Landing page |
/pricing | Plans (inert CTA when config.payment.enabled is false) |
/contact | Contact form |
/terms, /privacy, /cookies, /refund | Legal pages |
The one shared data export is socialProofAvatars in packages/config/src/landing.ts - five hero avatars self-hosted under public/avatars/. Replace the array (and the matching .webp files) when rebranding.
SEO & structured data
Every node is emitted with the <JsonLd> component (React-19-safe inline application/ld+json), cross-linked by @id into one graph, and assembled from config. Title / Open Graph / Twitter defaults come from the locale layout (app/[locale]/layout.tsx); twitter:site derives from config.social?.x.
| JSON-LD node | Emitted on | Built from |
|---|---|---|
Organization + WebSite | every page | config.business, config.social, config.seo, config.logo |
WebPage (+ BreadcrumbList) | every page | page title + description |
Article graph (Article + WebPage + Person + ImageObject) | blog posts | post frontmatter + author |
CollectionPage / SearchResultsPage | blog listings / search | post index |
FAQPage | /, /pricing | faqItems (i18n) |
Product ×N | /pricing | pricingConfig.plans |
SoftwareApplication + AggregateRating + Review | / | pricingConfig + lib/landing/testimonials.ts |
The SoftwareApplication node's AggregateRating + Review are built from lib/landing/testimonials.ts, which ships placeholder reviews. Publishing fake review markup risks a Google manual action - replace them with real ratings, or drop <ProductSchema /> from the landing page, before launch.
| Source key | Feeds | Default behavior |
|---|---|---|
config.indexable | app/robots.ts + app/sitemap.ts | true → Allow: / + sitemap; false → Disallow: /, empty sitemap |
config.business?.name (→ config.siteName) | JSON-LD name / legalName | Placeholder - edit before launch |
config.baseUrl | JSON-LD url, sitemap entries | Site origin |
config.logo?.main | JSON-LD logo | Brand logo path |
config.seo?.foundingDate | JSON-LD foundingDate | Optional |
config.social | JSON-LD sameAs array | Social profile links |
config.seo.description | Organization JSON-LD description + blog RSS channel | Not the page <meta description> (that's home.meta / page metadata) |
When config.blog.enabled is true, the WebSite node carries a SearchAction, blog routes are appended to the sitemap, and an RSS <link rel="alternate"> is added to <head>. Dashboard, admin, auth, /blog/search, and /unsubscribe use per-route noindex meta (Metadata.robots) rather than a robots.txt Disallow - a noindex tag lets crawlers read the directive and drop the page cleanly.
config.seo.description is not the page <meta description> (that comes from home.meta / per-page metadata) - it feeds the Organization JSON-LD description and the blog RSS channel. Edit config.business, config.social, and config.seo before launch; the defaults ship straight into your structured data.
Localized metadata: hreflang & canonical
Page <title>, description, canonical, and Open Graph all come from one helper - buildPageMetadata (lib/seo/page-metadata.ts). Resolve the localized title/description with getTranslations, then hand them to it along with the route's pathname:
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "pricing_page.meta" });
return buildPageMetadata({
locale,
pathname: "/pricing",
title: t("title"),
description: t("description", { product: config.siteName })
});
}Pages with bespoke metadata (blog posts, the search page) build their own Metadata and call canonicalFor directly for the canonical link.
| Signal | Where it comes from |
|---|---|
hreflang alternate links (incl. x-default) | Emitted automatically for every locale by next-intl's middleware as an HTTP Link response header (configured in i18n/routing.ts), so they are never hand-written per page. |
| canonical | Set per page by buildPageMetadata / canonicalFor (lib/seo.ts) - the middleware does not emit canonical. |
| Localized URLs | The sitemap lists one entry per route and locale. |
hreflang lives in the HTTP Link header, not the HTML <head>, so "view source" will not show it. Confirm it with curl -I https://yoursite.com/pricing (or the browser devtools Network tab): you should see a hreflang entry for each locale plus x-default.
Frequently asked questions
Where do I edit the landing copy?
In packages/i18n/translations/en/web.json - section copy under landing.*, the landing <title> / meta description under home.meta. Never in config. Only edit the en file; other locales are generated.
How do I turn the whole site into a pre-launch capture page?
Set the config.waitlist flag. It swaps the hero/CTA for an email-capture form and gates accounts - see the Waitlist page.
Why is Google not indexing my site?
Check config.indexable - when false (demos/staging), app/robots.ts returns Disallow: / and the sitemap is empty. Set it to true for production.
Why aren't my blog posts in the sitemap?
Blog routes and the WebSite SearchAction only appear when config.blog.enabled is true.
Banners
Add marketing and dashboard announcement banners in your Next.js SaaS, toggled by config.banner with role and plan targeting and dismissal.
Waitlist Mode
Turn your Next.js SaaS into a pre-launch waitlist with one config flag. Signups capture emails via Better Auth and the hero becomes a sign-up form.