@decocms/start 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,44 +45,24 @@ import { createFileRoute } from "@tanstack/react-router";
45
45
  import { cmsRouteConfig, loadDeferredSection } from "@decocms/start/routes";
46
46
  import { DecoPageRenderer } from "@decocms/start/hooks";
47
47
  import type { ResolvedSection, DeferredSection } from "@decocms/start/cms";
48
- import type { CacheProfile } from "@decocms/start/sdk/cacheHeaders";
49
- import { cacheHeaders, routeCacheDefaults } from "@decocms/start/sdk/cacheHeaders";
50
48
 
51
49
  const routeConfig = cmsRouteConfig({
52
50
  siteName: "My Store",
53
51
  defaultTitle: "My Store - Default Title",
52
+ defaultDescription: "My Store — best products with the best prices.",
54
53
  ignoreSearchParams: ["skuId"],
55
54
  });
56
55
 
57
56
  type PageData = {
58
57
  resolvedSections: ResolvedSection[];
59
58
  deferredSections: DeferredSection[];
60
- cacheProfile: CacheProfile;
61
59
  name: string;
62
60
  path: string;
63
61
  params: Record<string, string>;
64
62
  } | null;
65
63
 
66
64
  export const Route = createFileRoute("/$")({
67
- ...routeCacheDefaults("listing"),
68
- loaderDeps: routeConfig.loaderDeps,
69
- loader: routeConfig.loader as any,
70
- headers: ({ loaderData }) => {
71
- const data = loaderData as PageData;
72
- return cacheHeaders(data?.cacheProfile ?? "listing");
73
- },
74
- head: ({ loaderData }) => {
75
- const data = loaderData as PageData;
76
- return {
77
- meta: [
78
- {
79
- title: data?.name
80
- ? `${data.name} | My Store`
81
- : "My Store - Default Title",
82
- },
83
- ],
84
- };
85
- },
65
+ ...routeConfig,
86
66
  component: CmsPage,
87
67
  notFoundComponent: NotFoundPage,
88
68
  });
@@ -105,15 +85,17 @@ function CmsPage() {
105
85
  }
106
86
  ```
107
87
 
108
- **CRITICAL**: The `...routeCacheDefaults("listing")` spread is essential. Without it, every SPA navigation triggers a full server re-fetch even when the data was just loaded seconds ago. This is the most common cause of perceived slow navigation.
88
+ **CRITICAL**: `cmsRouteConfig` already includes `routeCacheDefaults("product")`, cache headers, and full SEO head metadata. Spread the entire config do NOT cherry-pick individual fields.
109
89
 
110
90
  ### `cmsRouteConfig` Options
111
91
 
112
92
  ```typescript
113
93
  interface CmsRouteOptions {
114
- siteName: string; // Used in page title: "Page Name | siteName"
115
- defaultTitle: string; // Fallback title when CMS page has no name
116
- ignoreSearchParams?: string[]; // Search params excluded from loaderDeps
94
+ siteName: string; // Used in page title: "Page Name | siteName"
95
+ defaultTitle: string; // Fallback title when CMS page has no name
96
+ defaultDescription?: string; // Fallback description when no SEO section contributes one
97
+ ignoreSearchParams?: string[]; // Search params excluded from loaderDeps (default: ["skuId"])
98
+ pendingComponent?: () => any; // Skeleton shown during SPA navigation
117
99
  }
118
100
  ```
119
101
 
@@ -160,17 +142,23 @@ The `cacheProfile` is determined by `detectCacheProfile(basePath)` inside `loadC
160
142
  | `/cart`, `/checkout` | private | none |
161
143
  | Everything else | listing | 2 min |
162
144
 
163
- ### Head/SEO
145
+ ### Head/SEO — Automatic from CMS `page.seo` + Section Registry
164
146
 
165
- ```typescript
166
- head: ({ loaderData }) => ({
167
- meta: [
168
- { title: loaderData?.pageName
169
- ? `${loaderData.pageName} | ${siteName}`
170
- : defaultTitle },
171
- ],
172
- }),
173
- ```
147
+ The framework's `buildHead()` function generates full `<head>` metadata from two sources:
148
+
149
+ **Primary: `page.seo` field** — The top-level `seo` block in CMS page JSONs is resolved eagerly by `resolvePageSeoBlock()`. Lazy/Deferred wrappers are always unwrapped (SEO must never be deferred for crawlers). Commerce loaders within the seo block are resolved (e.g., PDP product data). Section loaders transform the resolved props into standard SEO fields.
150
+
151
+ **Secondary: Registered SEO sections** — Sections in `page.sections` registered via `registerSeoSections()` contribute SEO as a fallback. Page-level `page.seo` always takes precedence.
152
+
153
+ Generated tags:
154
+ - `<title>` from page.seo → section SEO → page name + siteName → defaultTitle
155
+ - `<meta name="description">` from page.seo → section SEO → defaultDescription
156
+ - `<link rel="canonical">` from page.seo canonical
157
+ - `<meta property="og:*">` Open Graph tags (title, description, image, type, url)
158
+ - `<meta name="twitter:*">` Twitter Card tags
159
+ - `<meta name="robots">` noindex/nofollow when `noIndexing: true`
160
+
161
+ Title/description templates from the CMS (e.g., `"%s | STORE NAME"`) are applied automatically. The `head()` function is built into `cmsRouteConfig` — sites do NOT need to implement their own.
174
162
 
175
163
  ---
176
164
 
@@ -187,22 +175,20 @@ import { cmsHomeRouteConfig, loadDeferredSection } from "@decocms/start/routes";
187
175
  import { DecoPageRenderer } from "@decocms/start/hooks";
188
176
  import type { ResolvedSection, DeferredSection } from "@decocms/start/cms";
189
177
 
190
- const homeConfig = cmsHomeRouteConfig({
191
- defaultTitle: "My Store - Homepage",
192
- });
193
-
194
- type HomeData = {
195
- resolvedSections: ResolvedSection[];
196
- deferredSections: DeferredSection[];
197
- } | null;
198
-
199
178
  export const Route = createFileRoute("/")({
200
- ...homeConfig,
179
+ ...cmsHomeRouteConfig({
180
+ defaultTitle: "My Store - Homepage",
181
+ defaultDescription: "My Store — best products with the best prices.",
182
+ siteName: "My Store",
183
+ }),
201
184
  component: HomePage,
202
185
  });
203
186
 
204
187
  function HomePage() {
205
- const data = Route.useLoaderData() as HomeData;
188
+ const data = Route.useLoaderData() as {
189
+ resolvedSections: ResolvedSection[];
190
+ deferredSections: DeferredSection[];
191
+ } | null;
206
192
  if (!data) return null;
207
193
 
208
194
  return (
@@ -216,13 +202,16 @@ function HomePage() {
216
202
  }
217
203
  ```
218
204
 
219
- `cmsHomeRouteConfig` already includes `routeCacheDefaults("static")` and `cacheHeaders("static")`, giving the homepage a 5-min client staleTime and 24h edge TTL. Do NOT add additional cache config.
205
+ `cmsHomeRouteConfig` already includes `routeCacheDefaults("static")`, `cacheHeaders("static")`, and full SEO head metadata. Do NOT add additional cache or head config.
220
206
 
221
207
  ### `cmsHomeRouteConfig` Options
222
208
 
223
209
  ```typescript
224
210
  interface CmsHomeRouteOptions {
225
211
  defaultTitle: string;
212
+ defaultDescription?: string; // Fallback description
213
+ siteName?: string; // For OG title composition (defaults to defaultTitle)
214
+ pendingComponent?: () => any;
226
215
  }
227
216
  ```
228
217
 
@@ -289,17 +278,30 @@ TanStack Router injects internal properties (`id`, `path`) that conflict if the
289
278
  ```typescript
290
279
  // @decocms/start/routes
291
280
  export {
292
- cmsRouteConfig, // Catch-all CMS route config factory
293
- cmsHomeRouteConfig, // Homepage route config factory
281
+ cmsRouteConfig, // Catch-all CMS route config factory (includes full SEO head)
282
+ cmsHomeRouteConfig, // Homepage route config factory (includes full SEO head)
294
283
  loadCmsPage, // Server function for CMS page resolution
295
284
  loadCmsHomePage, // Server function for homepage resolution
285
+ loadDeferredSection, // Server function for on-scroll section loading
296
286
  type CmsRouteOptions,
287
+ type PageSeo, // SEO data type extracted from sections
288
+ type Device, // Device type: "mobile" | "tablet" | "desktop"
297
289
  CmsPage, // Generic CMS page component
298
290
  NotFoundPage, // Generic 404 component
299
291
  decoMetaRoute, // Admin meta route config
300
292
  decoRenderRoute, // Admin render route config
301
293
  decoInvokeRoute, // Admin invoke route config
302
294
  };
295
+
296
+ // @decocms/start/cms
297
+ export {
298
+ registerSeoSections, // Register section keys that contribute page SEO (secondary source)
299
+ extractSeoFromProps, // Extract SEO fields from any section's props
300
+ extractSeoFromSections, // Extract SEO from registered sections (used internally)
301
+ resolvePageSeoBlock, // Resolve page.seo CMS block eagerly (used internally)
302
+ type PageSeo, // { title, description, canonical, image, noIndexing, jsonLDs, type }
303
+ // ... all existing exports
304
+ };
303
305
  ```
304
306
 
305
307
  Add to `package.json` exports:
@@ -346,6 +348,135 @@ The root route contains site-specific elements that should NOT be in the framewo
346
348
  - Font loading
347
349
  - Theme configuration
348
350
  - QueryClient setup
351
+ - **Default description and OG site_name/locale** — root-level `head()` should include fallback `<meta name="description">`, `og:site_name`, and `og:locale`. Child routes (from `cmsRouteConfig`) override these when section SEO provides better values.
352
+
353
+ ```typescript
354
+ // src/routes/__root.tsx
355
+ export const Route = createRootRoute({
356
+ head: () => ({
357
+ meta: [
358
+ { charSet: "utf-8" },
359
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
360
+ { title: "My Store - Default Title" },
361
+ { name: "description", content: "My Store — default description for all pages." },
362
+ { property: "og:site_name", content: "My Store" },
363
+ { property: "og:locale", content: "pt_BR" },
364
+ ],
365
+ links: [
366
+ { rel: "stylesheet", href: appCss },
367
+ { rel: "icon", href: "/favicon.ico" },
368
+ ],
369
+ }),
370
+ component: RootLayout,
371
+ });
372
+ ```
373
+
374
+ **Do NOT include a `Device.Provider` with hardcoded values.** For client-side device detection, use `useSyncExternalStore` + `window.matchMedia`. For server-side, use section loaders via `registerSectionLoaders` (they receive the request and can detect UA).
375
+
376
+ ---
377
+
378
+ ## SEO Architecture
379
+
380
+ SEO in @decocms/start works across four layers:
381
+
382
+ ### 1. CMS `page.seo` Block (primary source)
383
+
384
+ CMS page JSONs have a top-level `seo` field separate from `sections`. This is the **primary** SEO data source, processed by `resolvePageSeoBlock()` in `resolve.ts`.
385
+
386
+ **Key behavior: Lazy/Deferred wrappers are always unwrapped.** SEO metadata must be in the initial SSR HTML for crawlers. The original Fresh/Deno framework did NOT do this, causing PDP pages to have zero SSR SEO when `page.seo` was wrapped in `Lazy.tsx`. We fix this by design.
387
+
388
+ Resolution pipeline:
389
+ 1. Unwrap Lazy/Deferred (unlimited depth)
390
+ 2. Follow named block references
391
+ 3. Evaluate multivariate flags
392
+ 4. Resolve all nested `__resolveType` (commerce loaders for product data)
393
+ 5. Return `ResolvedSection` in `DecoPageResult.seoSection`
394
+
395
+ In `cmsRoute.ts`, the seoSection is enriched by its section loader, then:
396
+ - `extractSeoFromProps()` picks title/description/canonical/image/noIndexing/jsonLDs/type
397
+ - `titleTemplate` / `descriptionTemplate` from the CMS block are applied (e.g., `"%s | STORE NAME"`)
398
+
399
+ ### 2. Page-Level Meta (framework `head()`)
400
+
401
+ `cmsRouteConfig` and `cmsHomeRouteConfig` generate `<head>` metadata automatically from the merged `PageSeo` object (page.seo primary + sections secondary). Includes title, description, canonical, OG (title, description, image, type, url), Twitter Card, and robots.
402
+
403
+ ### 3. Section-Contributed SEO (secondary source, `registerSeoSections`)
404
+
405
+ Sections in `page.sections` that also contribute SEO metadata register themselves in `setup.ts`:
406
+
407
+ ```typescript
408
+ import { registerSeoSections } from "@decocms/start/cms";
409
+
410
+ registerSeoSections([
411
+ "site/sections/SEOPDP.tsx", // Product structured data + meta
412
+ "site/sections/SEOPLP.tsx", // Category/search meta
413
+ ]);
414
+ ```
415
+
416
+ These sections must have a **section loader** that returns props with SEO fields:
417
+
418
+ ```typescript
419
+ interface PageSeo {
420
+ title?: string;
421
+ description?: string;
422
+ canonical?: string;
423
+ image?: string;
424
+ noIndexing?: boolean;
425
+ jsonLDs?: Record<string, any>[];
426
+ type?: string; // og:type: "website", "product", etc.
427
+ }
428
+ ```
429
+
430
+ After `runSectionLoaders`, the framework scans registered SEO sections and extracts these fields. Page.seo fields take precedence when both sources provide the same field.
431
+
432
+ ### 4. Structured Data (section component)
433
+
434
+ JSON-LD (`<script type="application/ld+json">`) is rendered by the section component itself — NOT in `<head>`. The section receives `jsonLDs` in its props and renders them:
435
+
436
+ ```typescript
437
+ // src/components/ui/Seo.tsx
438
+ export default function Seo({ jsonLDs }: Props) {
439
+ if (!jsonLDs?.length) return null;
440
+ return (
441
+ <>
442
+ {jsonLDs.map((jsonLD, i) => (
443
+ <script
444
+ key={i}
445
+ type="application/ld+json"
446
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLD) }}
447
+ />
448
+ ))}
449
+ </>
450
+ );
451
+ }
452
+ ```
453
+
454
+ ### SEO Data Flow
455
+
456
+ ```
457
+ CMS Page JSON
458
+ ├─ page.sections[] → resolveDecoPage → runSectionLoaders
459
+ │ → extractSeoFromSections() (secondary SEO source)
460
+
461
+ └─ page.seo → resolvePageSeoBlock (unwrap Lazy, resolve commerce loaders)
462
+ → runSingleSectionLoader (SEOPDP transforms jsonLD → title/desc/etc.)
463
+ → extractSeoFromProps() → apply titleTemplate/descriptionTemplate
464
+ → PRIMARY PageSeo
465
+
466
+ Merged PageSeo = { ...sectionSeo, ...pageSeo }
467
+ → cmsRouteConfig head() → emits <title>, <meta>, <link>, OG, Twitter, robots
468
+ → Section component renders JSON-LD in page body
469
+ ```
470
+
471
+ ### Checklist for New Sites
472
+
473
+ 1. **`__root.tsx`**: Include fallback `description`, `og:site_name`, `og:locale`
474
+ 2. **`$.tsx` / `index.tsx`**: Pass `siteName`, `defaultTitle`, `defaultDescription` to `cmsRouteConfig` / `cmsHomeRouteConfig`
475
+ 3. **`setup.ts`**: Register section loaders for any site SEO sections (e.g., SEOPDP) that appear in `page.seo` CMS blocks
476
+ 4. **`setup.ts`**: Optionally call `registerSeoSections([...])` for sections in `page.sections` that contribute SEO
477
+ 5. **`Seo.tsx`**: Component renders JSON-LD (NOT meta tags — framework handles those)
478
+ 6. **Device**: Use `matchMedia` for client-side, section loaders for server-side — NO hardcoded `Device.Provider`
479
+ 7. **CMS audit**: Verify PDP `page.seo` blocks are NOT wrapped in `Lazy.tsx` with no inner section — the framework unwraps Lazy, but the inner section must exist
349
480
 
350
481
  ---
351
482
 
@@ -190,6 +190,82 @@ import { addSkipResolveType } from "@decocms/start/cms";
190
190
  addSkipResolveType("custom/loaders/myCustomLoader.ts");
191
191
  ```
192
192
 
193
+ ### Page-Level SEO Resolution (`page.seo`)
194
+
195
+ CMS page JSONs have a top-level `seo` field separate from `sections`:
196
+
197
+ ```json
198
+ {
199
+ "name": "Home",
200
+ "path": "/",
201
+ "sections": [ ... ],
202
+ "seo": {
203
+ "__resolveType": "website/sections/Seo/SeoV2.tsx",
204
+ "title": "My Store",
205
+ "description": "Best prices",
206
+ "canonical": "https://example.com/",
207
+ "jsonLDs": [{ "@type": "Organization", ... }],
208
+ "titleTemplate": "%s",
209
+ "descriptionTemplate": "%s",
210
+ "type": "website"
211
+ }
212
+ }
213
+ ```
214
+
215
+ For PDP pages, `page.seo` is often wrapped in `Lazy.tsx` with a commerce loader:
216
+
217
+ ```json
218
+ {
219
+ "seo": {
220
+ "__resolveType": "website/sections/Rendering/Lazy.tsx",
221
+ "section": {
222
+ "__resolveType": "site/sections/SEOPDP.tsx",
223
+ "jsonLD": { "__resolveType": "Intelligent Search PDP Loader" },
224
+ "titleTemplate": "%s | STORE NAME",
225
+ "descriptionTemplate": "%s | STORE NAME"
226
+ }
227
+ }
228
+ }
229
+ ```
230
+
231
+ `resolvePageSeoBlock()` handles this by:
232
+ 1. **Always unwrapping Lazy/Deferred** — SEO must never be deferred for crawlers
233
+ 2. Following named block references and multivariate flags
234
+ 3. Resolving all nested `__resolveType` props (commerce loaders for PDP product data)
235
+ 4. Returning a `ResolvedSection` with the component key and resolved props
236
+
237
+ The result is stored in `DecoPageResult.seoSection` and processed in `cmsRoute.ts`:
238
+ 1. Run the registered section loader (e.g., SEOPDP transforms `{jsonLD: ProductDetailsPage}` → `{title, description, canonical, image, jsonLDs}`)
239
+ 2. Extract `PageSeo` fields via `extractSeoFromProps()`
240
+ 3. Apply `titleTemplate`/`descriptionTemplate` from the CMS config (e.g., `"%s | STORE NAME"`)
241
+ 4. Merge with section-contributed SEO (page.seo is primary, sections are secondary)
242
+
243
+ ```typescript
244
+ interface PageSeo {
245
+ title?: string;
246
+ description?: string;
247
+ canonical?: string;
248
+ image?: string;
249
+ noIndexing?: boolean;
250
+ jsonLDs?: Record<string, any>[];
251
+ type?: string; // og:type: "website", "product", etc.
252
+ }
253
+ ```
254
+
255
+ `buildHead()` in `cmsRouteConfig` generates full `<head>` metadata from `PageSeo`: `<title>`, `<meta name="description">`, `<link rel="canonical">`, Open Graph (including `og:url`, `og:type`), Twitter Card, and robots directives.
256
+
257
+ ### SEO Section Registry (secondary source)
258
+
259
+ Sections in `page.sections` that also contribute SEO metadata are registered via `registerSeoSections()`:
260
+
261
+ ```typescript
262
+ registerSeoSections(["site/sections/SEOPDP.tsx"]);
263
+ ```
264
+
265
+ `extractSeoFromSections()` scans registered sections and extracts SEO fields. This is the **secondary** source — `page.seo` always takes precedence when both are present.
266
+
267
+ Note: PLP and search pages typically lack a `page.seo` field. For these, `cmsRouteConfig` falls back to composing a title from the page name and `siteName` option.
268
+
193
269
  ### PostHog Matchers (`matchers/posthog.ts`)
194
270
 
195
271
  Server-side PostHog feature flag evaluation:
@@ -997,11 +997,126 @@ export default defineConfig({
997
997
 
998
998
  ---
999
999
 
1000
+ ## 25. SEO Architecture — Three Layers
1001
+
1002
+ ### Problem
1003
+
1004
+ Sites migrated from Fresh/Deno often have broken SEO:
1005
+ - `Seo.tsx` component returns `null` (dead stub from migration)
1006
+ - Route `head()` functions only set `<title>`, missing description/canonical/OG
1007
+ - No JSON-LD structured data on PDPs
1008
+ - No Open Graph tags for social sharing
1009
+
1010
+ ### Solution — Framework + Section + Component
1011
+
1012
+ **Layer 1: Framework head()** (`cmsRouteConfig` / `cmsHomeRouteConfig`)
1013
+ Automatically emits title, description, canonical, OG, Twitter Card, robots from `DecoPageResult.seo`. Requires `defaultDescription` option and `registerSeoSections()` in setup.
1014
+
1015
+ **Layer 2: Section SEO** (`registerSeoSections` + `registerSectionLoaders`)
1016
+ Sections like SEOPDP register as SEO contributors. Their section loader computes title/description/canonical from commerce data (product name, product description, breadcrumb canonical). The framework extracts these into `PageSeo`.
1017
+
1018
+ **Layer 3: Structured data** (section component renders JSON-LD)
1019
+ The `Seo.tsx` component renders `<script type="application/ld+json">` for Product, WebSite, BreadcrumbList schemas. Meta tags are NOT rendered here — the framework handles those via `head()`.
1020
+
1021
+ ### Setup Checklist
1022
+
1023
+ ```typescript
1024
+ // setup.ts
1025
+ import { registerSeoSections } from "@decocms/start/cms";
1026
+
1027
+ registerSeoSections(["site/sections/SEOPDP.tsx"]);
1028
+
1029
+ // Also register the SEOPDP section loader:
1030
+ registerSectionLoaders({
1031
+ "site/sections/SEOPDP.tsx": async (props, req) => {
1032
+ const mod = await import("./sections/SEOPDP");
1033
+ return mod.loader(props, req, { seo: {} } as any) ?? props;
1034
+ },
1035
+ });
1036
+ ```
1037
+
1038
+ ```typescript
1039
+ // routes/$.tsx — spread full config, framework handles SEO
1040
+ const routeConfig = cmsRouteConfig({
1041
+ siteName: "My Store",
1042
+ defaultTitle: "My Store - Default Title",
1043
+ defaultDescription: "My Store — best products...",
1044
+ ignoreSearchParams: ["skuId"],
1045
+ });
1046
+ export const Route = createFileRoute("/$")({ ...routeConfig, component: CmsPage });
1047
+ ```
1048
+
1049
+ ```typescript
1050
+ // __root.tsx — fallback description and OG globals
1051
+ head: () => ({
1052
+ meta: [
1053
+ { charSet: "utf-8" },
1054
+ { name: "viewport", content: "width=device-width, initial-scale=1" },
1055
+ { title: "My Store - Default Title" },
1056
+ { name: "description", content: "My Store — default description." },
1057
+ { property: "og:site_name", content: "My Store" },
1058
+ { property: "og:locale", content: "pt_BR" },
1059
+ ],
1060
+ }),
1061
+ ```
1062
+
1063
+ ### Anti-Patterns
1064
+
1065
+ - `Seo.tsx` returning `null` — must render JSON-LD at minimum
1066
+ - Custom `head()` in routes that only sets title — use `cmsRouteConfig` which handles full SEO
1067
+ - Hardcoded Device.Provider — use matchMedia for client, section loaders for server
1068
+
1069
+ ---
1070
+
1071
+ ## 26. Device Detection — No Hardcoded Provider
1072
+
1073
+ ### Problem
1074
+
1075
+ Sites often have `<Device.Provider value={{ isMobile: false }}>` in `__root.tsx`, making ALL client-side components think they're on desktop regardless of actual viewport.
1076
+
1077
+ ### Solution — Two-Layer Detection
1078
+
1079
+ **Server-side**: Section loaders via `registerSectionLoaders()` detect device from UA and inject `isMobile` / `device` as props. The page result also includes `device` for client use.
1080
+
1081
+ **Client-side**: Use `useSyncExternalStore` + `window.matchMedia` instead of React context:
1082
+
1083
+ ```typescript
1084
+ // contexts/device.tsx
1085
+ import { useSyncExternalStore } from "react";
1086
+
1087
+ const MOBILE_QUERY = "(max-width: 767px)";
1088
+
1089
+ function subscribe(cb: () => void) {
1090
+ if (typeof window === "undefined") return () => {};
1091
+ const mql = window.matchMedia(MOBILE_QUERY);
1092
+ mql.addEventListener("change", cb);
1093
+ return () => mql.removeEventListener("change", cb);
1094
+ }
1095
+
1096
+ function getSnapshot() { return window.matchMedia(MOBILE_QUERY).matches; }
1097
+ function getServerSnapshot() { return false; }
1098
+
1099
+ export const useDevice = () => {
1100
+ const isMobile = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
1101
+ return { isMobile };
1102
+ };
1103
+ ```
1104
+
1105
+ ### Why matchMedia > UA context
1106
+
1107
+ - Reflects actual viewport, not a server guess
1108
+ - Reactive — updates if user resizes browser
1109
+ - No Provider needed — works anywhere in the component tree
1110
+ - SSR defaults to desktop (false), hydrates to real value
1111
+
1112
+ ---
1113
+
1000
1114
  ## Related Skills
1001
1115
 
1002
1116
  | Skill | Purpose |
1003
1117
  |-------|---------|
1004
1118
  | `deco-to-tanstack-migration` | Initial migration playbook (imports, signals, architecture) |
1119
+ | `deco-cms-route-config` | Route config, SEO architecture, device detection |
1005
1120
  | `deco-tanstack-navigation` | SPA navigation patterns (`<Link>`, `useNavigate`, `loaderDeps`) |
1006
1121
  | `deco-islands-migration` | Eliminating the `src/islands/` directory |
1007
1122
  | `deco-edge-caching` | Edge caching with `createDecoWorkerEntry` |
@@ -235,6 +235,7 @@ See: skill `deco-islands-migration`
235
235
  6. Wire `onBeforeResolve()` → `initVtexFromBlocks()` for VTEX config
236
236
  7. Configure `setAsyncRenderingConfig()` with `alwaysEager` for critical sections
237
237
  8. Configure admin: `setMetaData()`, `setRenderShell()`, `setInvokeLoaders()`
238
+ 9. **Register SEO sections via `registerSeoSections()`** — identify sections that produce title/description/canonical (typically SEOPDP, SEOPLP). Register their loaders in `registerSectionLoaders` too.
238
239
 
239
240
  **Template**: `templates/setup-ts.md`
240
241
 
@@ -250,15 +251,17 @@ See: skill `deco-async-rendering-site-guide`
250
251
 
251
252
  **Actions**:
252
253
  1. Create `src/router.tsx` with scroll restoration
253
- 2. Create `src/routes/__root.tsx` with QueryClient, LiveControls, NavigationProgress, analytics
254
- 3. Create `src/routes/index.tsx` using `cmsHomeRouteConfig()`
255
- 4. Create `src/routes/$.tsx` using `cmsRouteConfig()`
254
+ 2. Create `src/routes/__root.tsx` with QueryClient, LiveControls, NavigationProgress, analytics. **Include fallback `description`, `og:site_name`, `og:locale` in root `head()`.** Do NOT include a hardcoded `Device.Provider`.
255
+ 3. Create `src/routes/index.tsx` using `cmsHomeRouteConfig({ defaultTitle, defaultDescription, siteName })`
256
+ 4. Create `src/routes/$.tsx` using `cmsRouteConfig({ siteName, defaultTitle, defaultDescription })` — spread the full config, do NOT cherry-pick fields. The framework handles SEO head, cache headers, and staleTime/gcTime.
257
+ 5. **Create/port `Seo.tsx` component** — must render JSON-LD structured data (NOT return null). Meta tags are handled by the framework's `head()`.
258
+ 6. **Port device detection** — use `useSyncExternalStore` + `matchMedia` for client-side. Use `registerSectionLoaders` for server-side UA detection. Do NOT use a hardcoded `Device.Provider`.
256
259
 
257
260
  **Templates**: `templates/root-route.md`, `templates/router.md`
258
261
 
259
- **Exit**: Routes compile, CMS pages resolve
262
+ **Exit**: Routes compile, CMS pages resolve, PDPs have JSON-LD + meta description in `<head>`
260
263
 
261
- See: skill `deco-tanstack-navigation`
264
+ See: skills `deco-tanstack-navigation`, `deco-cms-route-config`
262
265
 
263
266
  ---
264
267
 
@@ -566,6 +569,7 @@ The conductor approach that worked (836 errors → 0 across 213 files) treated e
566
569
  | deco-apps-vtex-porting | Understanding VTEX loader internals (Phase 4-5) |
567
570
  | deco-islands-migration | Eliminating islands/ (Phase 6) |
568
571
  | deco-async-rendering-site-guide | Lazy wrappers, LoadingFallback (Phase 7, 10) |
572
+ | deco-cms-route-config | Route config, SEO architecture, device detection (Phase 7-8) |
569
573
  | deco-tanstack-navigation | Link, prefetch, scroll issues (Phase 8) |
570
574
  | deco-edge-caching | Worker caching, cache profiles (Phase 9) |
571
575
  | deco-tanstack-hydration-fixes | Hydration mismatches post-migration |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
package/src/cms/index.ts CHANGED
@@ -30,19 +30,25 @@ export type {
30
30
  DecoPageResult,
31
31
  DeferredSection,
32
32
  MatcherContext,
33
+ PageSeo,
33
34
  ResolvedSection,
34
35
  ResolveErrorHandler,
35
36
  } from "./resolve";
36
37
  export {
37
38
  addSkipResolveType,
38
39
  evaluateMatcher,
40
+ extractSeoFromProps,
41
+ extractSeoFromSections,
39
42
  getAsyncRenderingConfig,
43
+ isSeoSection,
40
44
  onBeforeResolve,
41
45
  registerBotPattern,
42
46
  registerCommerceLoader,
43
47
  registerCommerceLoaders,
44
48
  registerMatcher,
49
+ registerSeoSections,
45
50
  resolveDecoPage,
51
+ resolvePageSeoBlock,
46
52
  resolveDeferredSection,
47
53
  resolveValue,
48
54
  setAsyncRenderingConfig,
@@ -615,6 +615,104 @@ async function resolveRawSection(
615
615
  return results;
616
616
  }
617
617
 
618
+ // ---------------------------------------------------------------------------
619
+ // Page-level SEO block resolution
620
+ // ---------------------------------------------------------------------------
621
+
622
+ /**
623
+ * Resolve the page-level `seo` field from the CMS page JSON.
624
+ *
625
+ * The CMS stores SEO config in `page.seo` as a separate field from
626
+ * `page.sections`. This field is typically a SeoV2.tsx block (homepage)
627
+ * or a site SEO section like SEOPDP.tsx wrapped in Lazy (PDP).
628
+ *
629
+ * This function always resolves eagerly — SEO metadata must never be
630
+ * deferred because search engine crawlers need it in the initial HTML.
631
+ * Lazy/Deferred wrappers are unwrapped, block references are followed,
632
+ * and commerce loader refs (e.g. PDP jsonLD) are resolved.
633
+ *
634
+ * Returns a ResolvedSection with the final component key and resolved props,
635
+ * or null if the seo field is absent/unresolvable.
636
+ */
637
+ export async function resolvePageSeoBlock(
638
+ seoBlock: Record<string, unknown> | undefined,
639
+ rctx: ResolveContext,
640
+ ): Promise<ResolvedSection | null> {
641
+ if (!seoBlock || typeof seoBlock !== "object") return null;
642
+
643
+ const blocks = loadBlocks();
644
+ let current = seoBlock;
645
+
646
+ for (let depth = 0; depth < 10; depth++) {
647
+ const rt = current.__resolveType as string | undefined;
648
+ if (!rt) return null;
649
+
650
+ // Lazy wrapper — always unwrap for SEO (never defer)
651
+ if (rt === "website/sections/Rendering/Lazy.tsx") {
652
+ const inner = current.section;
653
+ if (!inner || typeof inner !== "object") return null;
654
+ current = inner as Record<string, unknown>;
655
+ continue;
656
+ }
657
+
658
+ // Deferred wrapper — unwrap
659
+ if (rt === "website/sections/Rendering/Deferred.tsx") {
660
+ const inner = current.sections;
661
+ if (!inner || typeof inner !== "object") return null;
662
+ if (Array.isArray(inner) && inner.length === 1 && inner[0] && typeof inner[0] === "object") {
663
+ current = inner[0] as Record<string, unknown>;
664
+ continue;
665
+ }
666
+ return null;
667
+ }
668
+
669
+ // Multivariate flag — evaluate matcher and follow matched variant
670
+ if (
671
+ rt === "website/flags/multivariate.ts" ||
672
+ rt === "website/flags/multivariate/section.ts"
673
+ ) {
674
+ const variants = current.variants as Array<{ value: unknown; rule?: unknown }> | undefined;
675
+ if (!variants?.length) return null;
676
+ let matched: unknown = null;
677
+ for (const variant of variants) {
678
+ const rule = variant.rule as Record<string, unknown> | undefined;
679
+ if (evaluateMatcher(rule, rctx.matcherCtx)) {
680
+ matched = variant.value;
681
+ break;
682
+ }
683
+ }
684
+ if (!matched || typeof matched !== "object") return null;
685
+ current = matched as Record<string, unknown>;
686
+ continue;
687
+ }
688
+
689
+ // Named block reference — follow the chain
690
+ const block = blocks[rt] as Record<string, unknown> | undefined;
691
+ if (block) {
692
+ const { __resolveType: _, ...overrides } = current;
693
+ current = { ...block, ...overrides };
694
+ continue;
695
+ }
696
+
697
+ // Terminal section (site section or framework SEO type).
698
+ // Resolve all nested prop __resolveType refs (commerce loaders, etc.).
699
+ const { __resolveType: _, ...rawProps } = current;
700
+ try {
701
+ const resolvedProps = await resolveProps(rawProps, rctx);
702
+ return {
703
+ component: rt,
704
+ props: resolvedProps as Record<string, unknown>,
705
+ key: `seo:${rt}`,
706
+ };
707
+ } catch (e) {
708
+ onResolveError(e, rt, "Page SEO resolution");
709
+ return null;
710
+ }
711
+ }
712
+
713
+ return null;
714
+ }
715
+
618
716
  /**
619
717
  * Check if a raw CMS section block will produce a layout section.
620
718
  * Walks the block reference chain (up to 5 levels) to find the final
@@ -960,12 +1058,94 @@ async function resolveSectionsList(
960
1058
  return [];
961
1059
  }
962
1060
 
1061
+ // ---------------------------------------------------------------------------
1062
+ // Page-level SEO — extracted from registered SEO sections after resolution
1063
+ // ---------------------------------------------------------------------------
1064
+
1065
+ export interface PageSeo {
1066
+ title?: string;
1067
+ description?: string;
1068
+ canonical?: string;
1069
+ image?: string;
1070
+ noIndexing?: boolean;
1071
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1072
+ jsonLDs?: Record<string, any>[];
1073
+ /** Open Graph type — "website" (default), "product", etc. */
1074
+ type?: string;
1075
+ }
1076
+
1077
+ const seoSectionKeys: Set<string> = G.__deco.seoSections ?? (G.__deco.seoSections = new Set());
1078
+
1079
+ /**
1080
+ * Register section component keys whose resolved props contribute page-level
1081
+ * SEO metadata (title, description, canonical, image, jsonLDs, noIndexing).
1082
+ *
1083
+ * After section loaders run, the framework scans these sections and extracts
1084
+ * their SEO fields into `DecoPageResult.seo`, which `cmsRouteConfig` uses
1085
+ * to generate `<head>` metadata (meta tags, OG, canonical, robots).
1086
+ *
1087
+ * JSON-LD structured data stays in the section props for the section
1088
+ * component to render as `<script type="application/ld+json">`.
1089
+ */
1090
+ export function registerSeoSections(keys: string[]): void {
1091
+ for (const k of keys) seoSectionKeys.add(k);
1092
+ }
1093
+
1094
+ /** Check if a section key is registered as an SEO section. */
1095
+ export function isSeoSection(key: string): boolean {
1096
+ return seoSectionKeys.has(key);
1097
+ }
1098
+
1099
+ /**
1100
+ * Pick standard SEO fields from a props object.
1101
+ * Works for both framework SEO types (SeoV2) and site SEO sections (SEOPDP).
1102
+ */
1103
+ export function extractSeoFromProps(props: Record<string, unknown>): PageSeo {
1104
+ const seo: PageSeo = {};
1105
+ if (props.title) seo.title = props.title as string;
1106
+ if (props.description) seo.description = props.description as string;
1107
+ if (props.canonical) seo.canonical = props.canonical as string;
1108
+ if (props.image) seo.image = props.image as string;
1109
+ if (props.noIndexing !== undefined) seo.noIndexing = props.noIndexing as boolean;
1110
+ if (props.type) seo.type = props.type as string;
1111
+ if (Array.isArray(props.jsonLDs) && props.jsonLDs.length) {
1112
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1113
+ seo.jsonLDs = props.jsonLDs as Record<string, any>[];
1114
+ }
1115
+ return seo;
1116
+ }
1117
+
1118
+ /**
1119
+ * Extract SEO metadata from resolved sections registered via
1120
+ * `registerSeoSections`. Later sections override earlier ones
1121
+ * (e.g., a PDP SEO section overrides a generic page SEO).
1122
+ */
1123
+ export function extractSeoFromSections(sections: ResolvedSection[]): PageSeo {
1124
+ const seo: PageSeo = {};
1125
+ for (const section of sections) {
1126
+ if (!seoSectionKeys.has(section.component)) continue;
1127
+ const extracted = extractSeoFromProps(section.props);
1128
+ if (extracted.jsonLDs) {
1129
+ extracted.jsonLDs = [...(seo.jsonLDs ?? []), ...extracted.jsonLDs];
1130
+ }
1131
+ Object.assign(seo, extracted);
1132
+ }
1133
+ return seo;
1134
+ }
1135
+
963
1136
  export interface DecoPageResult {
964
1137
  name: string;
965
1138
  path: string;
966
1139
  params: Record<string, string>;
967
1140
  resolvedSections: ResolvedSection[];
968
1141
  deferredSections: DeferredSection[];
1142
+ /**
1143
+ * Resolved SEO block from the page-level `seo` field in the CMS JSON.
1144
+ * Contains the section component key and resolved props (with commerce
1145
+ * loader data already fetched). Needs section loader enrichment in
1146
+ * cmsRoute before SEO fields can be extracted.
1147
+ */
1148
+ seoSection?: ResolvedSection | null;
969
1149
  }
970
1150
 
971
1151
  export async function resolveDecoPage(
@@ -1072,12 +1252,27 @@ export async function resolveDecoPage(
1072
1252
 
1073
1253
  const allResults = await Promise.all(eagerResults);
1074
1254
 
1255
+ // Resolve page-level SEO block (page.seo field) — always eager.
1256
+ // Runs after sections to benefit from memoized commerce loader results.
1257
+ let seoSection: ResolvedSection | null = null;
1258
+ if (page.seo) {
1259
+ try {
1260
+ seoSection = await resolvePageSeoBlock(
1261
+ page.seo as Record<string, unknown>,
1262
+ rctx,
1263
+ );
1264
+ } catch (e) {
1265
+ onResolveError(e, "page.seo", "Page SEO block resolution");
1266
+ }
1267
+ }
1268
+
1075
1269
  return {
1076
1270
  name: page.name,
1077
1271
  path: page.path || targetPath,
1078
1272
  params,
1079
1273
  resolvedSections: allResults.flat(),
1080
1274
  deferredSections,
1275
+ seoSection,
1081
1276
  };
1082
1277
  }
1083
1278
 
@@ -30,8 +30,13 @@ import {
30
30
  } from "@tanstack/react-start/server";
31
31
  import { createElement } from "react";
32
32
  import { preloadSectionComponents } from "../cms/registry";
33
- import type { DeferredSection, MatcherContext, ResolvedSection } from "../cms/resolve";
34
- import { resolveDecoPage, resolveDeferredSection } from "../cms/resolve";
33
+ import type { DeferredSection, MatcherContext, PageSeo, ResolvedSection } from "../cms/resolve";
34
+ import {
35
+ extractSeoFromProps,
36
+ extractSeoFromSections,
37
+ resolveDecoPage,
38
+ resolveDeferredSection,
39
+ } from "../cms/resolve";
35
40
  import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
36
41
  import {
37
42
  type CacheProfile,
@@ -40,6 +45,7 @@ import {
40
45
  routeCacheDefaults,
41
46
  } from "../sdk/cacheHeaders";
42
47
  import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
48
+ import { type Device, detectDevice } from "../sdk/useDevice";
43
49
 
44
50
  const isServer = typeof document === "undefined";
45
51
 
@@ -87,12 +93,23 @@ async function loadCmsPageInternal(fullPath: string) {
87
93
  await preloadSectionComponents(eagerKeys);
88
94
 
89
95
  const cacheProfile = detectCacheProfile(basePath);
96
+ const ua = getRequestHeader("user-agent") ?? "";
97
+ const device = detectDevice(ua);
98
+
99
+ // Build SEO: merge page-level seo block (primary) with section-contributed SEO (secondary)
100
+ const seo = await buildPageSeo(page.seoSection, enrichedSections, request);
101
+
102
+ // Destructure seoSection out — it's an internal artifact, not serialized to client
103
+ const { seoSection: _seo, ...pageData } = page;
104
+
90
105
  return {
91
- ...page,
106
+ ...pageData,
92
107
  resolvedSections: normalizeUrlsInObject(enrichedSections),
93
108
  deferredSections: normalizeUrlsInObject(page.deferredSections),
94
109
  cacheProfile,
95
110
  pageUrl: urlWithSearch,
111
+ seo,
112
+ device,
96
113
  };
97
114
  }
98
115
 
@@ -116,8 +133,9 @@ export const loadCmsPage = createServerFn({ method: "GET" })
116
133
  */
117
134
  export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async () => {
118
135
  const request = getRequest();
136
+ const ua = getRequestHeader("user-agent") ?? "";
119
137
  const matcherCtx: MatcherContext = {
120
- userAgent: getRequestHeader("user-agent") ?? "",
138
+ userAgent: ua,
121
139
  url: getRequestUrl().toString(),
122
140
  path: "/",
123
141
  cookies: getCookies(),
@@ -130,10 +148,17 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
130
148
  const eagerKeys = enrichedSections.map((s) => s.component);
131
149
  await preloadSectionComponents(eagerKeys);
132
150
 
151
+ const device = detectDevice(ua);
152
+ const seo = await buildPageSeo(page.seoSection, enrichedSections, request);
153
+
154
+ const { seoSection: _seo, ...pageData } = page;
155
+
133
156
  return {
134
- ...page,
157
+ ...pageData,
135
158
  resolvedSections: normalizeUrlsInObject(enrichedSections),
136
159
  deferredSections: normalizeUrlsInObject(page.deferredSections),
160
+ seo,
161
+ device,
137
162
  };
138
163
  });
139
164
 
@@ -208,6 +233,8 @@ export interface CmsRouteOptions {
208
233
  siteName: string;
209
234
  /** Default page title when CMS page has no name. */
210
235
  defaultTitle: string;
236
+ /** Default description for pages without section-contributed SEO. */
237
+ defaultDescription?: string;
211
238
  /**
212
239
  * Search params to exclude from loader deps.
213
240
  * These params won't trigger a server re-fetch when they change.
@@ -218,15 +245,148 @@ export interface CmsRouteOptions {
218
245
  pendingComponent?: () => any;
219
246
  }
220
247
 
248
+ type CmsPageLoaderData = {
249
+ name?: string;
250
+ cacheProfile?: CacheProfile;
251
+ seo?: PageSeo;
252
+ device?: Device;
253
+ } | null;
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Page SEO assembly — merges page.seo block with section-contributed SEO
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Process the resolved SEO section from page.seo, run its section loader
261
+ * if registered, apply title/description templates, and merge with
262
+ * section-contributed SEO.
263
+ */
264
+ async function buildPageSeo(
265
+ seoSection: ResolvedSection | null | undefined,
266
+ enrichedSections: ResolvedSection[],
267
+ request: Request,
268
+ ): Promise<PageSeo> {
269
+ // Secondary source: SEO sections embedded in the sections array
270
+ const sectionSeo = extractSeoFromSections(enrichedSections);
271
+
272
+ if (!seoSection) return sectionSeo;
273
+
274
+ // Run the section loader on the seo section if one is registered
275
+ // (e.g., SEOPDP loader transforms {jsonLD: ProductDetailsPage} → {title, description, ...})
276
+ let enrichedProps = seoSection.props;
277
+ try {
278
+ const enriched = await runSingleSectionLoader(seoSection, request);
279
+ if (enriched) enrichedProps = enriched.props;
280
+ } catch {
281
+ // Section loader failed — use the raw resolved props
282
+ }
283
+
284
+ const pageSeo = extractSeoFromProps(enrichedProps);
285
+
286
+ // Apply title/description templates from the SEO block config.
287
+ // SeoV2 blocks carry templates like "%s | CASA & VIDEO" that wrap
288
+ // the computed title. Template "%s" is a no-op.
289
+ const rawProps = seoSection.props;
290
+ const titleTemplate = rawProps.titleTemplate as string | undefined;
291
+ const descTemplate = rawProps.descriptionTemplate as string | undefined;
292
+ if (titleTemplate && titleTemplate !== "%s" && pageSeo.title) {
293
+ pageSeo.title = titleTemplate.replace("%s", pageSeo.title);
294
+ }
295
+ if (descTemplate && descTemplate !== "%s" && pageSeo.description) {
296
+ pageSeo.description = descTemplate.replace("%s", pageSeo.description);
297
+ }
298
+
299
+ // Primary source (page.seo) overrides secondary (section-contributed).
300
+ // Only truthy fields in pageSeo override — undefined keys don't clear sectionSeo.
301
+ return { ...sectionSeo, ...pageSeo };
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Head metadata builder
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /**
309
+ * Build TanStack Router `head()` metadata from page-level and section-contributed SEO.
310
+ * Emits: title, description, canonical, OG tags, Twitter Card, robots directive.
311
+ */
312
+ function buildHead(
313
+ loaderData: CmsPageLoaderData | undefined,
314
+ siteName: string,
315
+ defaultTitle: string,
316
+ defaultDescription?: string,
317
+ ) {
318
+ const seo = loaderData?.seo;
319
+ const title = seo?.title
320
+ ? seo.title
321
+ : loaderData?.name
322
+ ? `${loaderData.name} | ${siteName}`
323
+ : defaultTitle;
324
+ const description = seo?.description || defaultDescription;
325
+ const image = seo?.image;
326
+ const canonical = seo?.canonical;
327
+ const noIndex = seo?.noIndexing;
328
+
329
+ const meta: Record<string, string>[] = [{ title }];
330
+
331
+ if (description) {
332
+ meta.push({ name: "description", content: description });
333
+ }
334
+
335
+ // Robots
336
+ if (noIndex) {
337
+ meta.push({ name: "robots", content: "noindex, nofollow" });
338
+ }
339
+
340
+ // Open Graph
341
+ meta.push({ property: "og:title", content: title });
342
+ if (description) meta.push({ property: "og:description", content: description });
343
+ if (image) meta.push({ property: "og:image", content: image });
344
+ meta.push({ property: "og:type", content: seo?.type || "website" });
345
+ if (canonical) meta.push({ property: "og:url", content: canonical });
346
+
347
+ // Twitter Card
348
+ meta.push({ name: "twitter:card", content: image ? "summary_large_image" : "summary" });
349
+ meta.push({ name: "twitter:title", content: title });
350
+ if (description) meta.push({ name: "twitter:description", content: description });
351
+ if (image) meta.push({ name: "twitter:image", content: image });
352
+
353
+ const links: Record<string, string>[] = [];
354
+ if (canonical) {
355
+ links.push({ rel: "canonical", href: canonical });
356
+ }
357
+
358
+ // JSON-LD structured data — rendered as <script type="application/ld+json"> in <head>
359
+ const scripts: Array<{ type: string; children: string }> = [];
360
+ if (seo?.jsonLDs?.length) {
361
+ for (const jsonLD of seo.jsonLDs) {
362
+ scripts.push({
363
+ type: "application/ld+json",
364
+ children: JSON.stringify(jsonLD),
365
+ });
366
+ }
367
+ }
368
+
369
+ return { meta, links, scripts };
370
+ }
371
+
221
372
  /**
222
373
  * Returns a TanStack Router route config object for a CMS catch-all route.
223
374
  * Spread the result into your `createFileRoute("/$")({...})` call.
224
375
  *
225
- * Includes: loaderDeps, loader, headers, head, staleTime/gcTime.
376
+ * Includes: loaderDeps, loader, headers, head (with full SEO), staleTime/gcTime.
226
377
  * Does NOT include: component, notFoundComponent (site provides these).
378
+ *
379
+ * SEO metadata is extracted from sections registered via `registerSeoSections()`.
380
+ * The `head()` function emits title, description, canonical, OG tags, and robots.
227
381
  */
228
382
  export function cmsRouteConfig(options: CmsRouteOptions) {
229
- const { siteName, defaultTitle, ignoreSearchParams = ["skuId"], pendingComponent } = options;
383
+ const {
384
+ siteName,
385
+ defaultTitle,
386
+ defaultDescription,
387
+ ignoreSearchParams = ["skuId"],
388
+ pendingComponent,
389
+ } = options;
230
390
 
231
391
  const ignoreSet = new Set(ignoreSearchParams);
232
392
 
@@ -253,9 +413,6 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
253
413
  : "";
254
414
  const page = await loadCmsPage({ data: basePath + searchStr });
255
415
 
256
- // On the client (SPA navigation or initial hydration), pre-import
257
- // eager section modules BEFORE React renders. This ensures
258
- // getResolvedComponent() returns a value and we skip React.lazy.
259
416
  if (!isServer && page?.resolvedSections) {
260
417
  const keys = page.resolvedSections.map((s: ResolvedSection) => s.component);
261
418
  await preloadSectionComponents(keys);
@@ -267,29 +424,31 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
267
424
 
268
425
  ...routeCacheDefaults("product"),
269
426
 
270
- headers: ({ loaderData }: { loaderData?: { cacheProfile?: CacheProfile } }) => {
427
+ headers: ({ loaderData }: { loaderData?: CmsPageLoaderData }) => {
271
428
  const profile = loaderData?.cacheProfile ?? "listing";
272
429
  return cacheHeaders(profile);
273
430
  },
274
431
 
275
- head: ({ loaderData }: { loaderData?: { name?: string } }) => ({
276
- meta: [
277
- {
278
- title: loaderData?.name ? `${loaderData.name} | ${siteName}` : defaultTitle,
279
- },
280
- ],
281
- }),
432
+ head: ({ loaderData }: { loaderData?: CmsPageLoaderData }) =>
433
+ buildHead(loaderData, siteName, defaultTitle, defaultDescription),
282
434
  };
283
435
  }
284
436
 
285
437
  /**
286
438
  * Returns a TanStack Router route config for the CMS homepage route.
287
439
  * Spread into `createFileRoute("/")({...})`.
440
+ *
441
+ * Like `cmsRouteConfig`, emits full SEO head metadata from section-contributed data.
288
442
  */
289
443
  export function cmsHomeRouteConfig(options: {
290
444
  defaultTitle: string;
445
+ defaultDescription?: string;
446
+ /** Site name for OG title composition. Defaults to defaultTitle. */
447
+ siteName?: string;
291
448
  pendingComponent?: () => any;
292
449
  }) {
450
+ const { defaultTitle, defaultDescription, siteName } = options;
451
+
293
452
  return {
294
453
  loader: async () => {
295
454
  const page = await loadCmsHomePage();
@@ -302,8 +461,7 @@ export function cmsHomeRouteConfig(options: {
302
461
  ...(options.pendingComponent ? { pendingComponent: options.pendingComponent } : {}),
303
462
  ...routeCacheDefaults("static"),
304
463
  headers: () => cacheHeaders("static"),
305
- head: () => ({
306
- meta: [{ title: options.defaultTitle }],
307
- }),
464
+ head: ({ loaderData }: { loaderData?: CmsPageLoaderData }) =>
465
+ buildHead(loaderData, siteName ?? defaultTitle, defaultTitle, defaultDescription),
308
466
  };
309
467
  }
@@ -13,3 +13,5 @@ export {
13
13
  loadDeferredSection,
14
14
  } from "./cmsRoute";
15
15
  export { CmsPage, NotFoundPage } from "./components";
16
+ export type { PageSeo } from "../cms/resolve";
17
+ export type { Device } from "../sdk/useDevice";