GenerateSaaS

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 from home.meta - not config.
  • Graph-linked JSON-LD - Organization + WebSite, a per-page WebPage, a blog-post Article graph, and a SoftwareApplication with review stars - plus config-driven sitemap + robots files.
  • Flag-aware sections: footer blog links gate on config.blog.enabled; pricing CTAs go inert when config.payment.enabled is false.
  • 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.

RoutePurpose
/Landing page
/pricingPlans (inert CTA when config.payment.enabled is false)
/contactContact form
/terms, /privacy, /cookies, /refundLegal 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 nodeEmitted onBuilt from
Organization + WebSiteevery pageconfig.business, config.social, config.seo, config.logo
WebPage (+ BreadcrumbList)every pagepage title + description
Article graph (Article + WebPage + Person + ImageObject)blog postspost frontmatter + author
CollectionPage / SearchResultsPageblog listings / searchpost index
FAQPage/, /pricingfaqItems (i18n)
Product ×N/pricingpricingConfig.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 keyFeedsDefault behavior
config.indexableapp/robots.ts + app/sitemap.tstrueAllow: / + sitemap; falseDisallow: /, empty sitemap
config.business?.name (→ config.siteName)JSON-LD name / legalNamePlaceholder - edit before launch
config.baseUrlJSON-LD url, sitemap entriesSite origin
config.logo?.mainJSON-LD logoBrand logo path
config.seo?.foundingDateJSON-LD foundingDateOptional
config.socialJSON-LD sameAs arraySocial profile links
config.seo.descriptionOrganization JSON-LD description + blog RSS channelNot 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.

SignalWhere 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.
canonicalSet per page by buildPageMetadata / canonicalFor (lib/seo.ts) - the middleware does not emit canonical.
Localized URLsThe 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.

On this page