SEO & resource hints
Canonical URLs, robots meta, favicons, and preconnect hints: the four template-level head decisions that affect crawlability and LCP.
Six head- and image-level gaps recur on storefronts scaffolded from the templates. They don't trip any build check but cost real LCP milliseconds and crawl-quality signal. They live in app/layout.tsx, the brand schema, and ejected card components, not in the cache topology, so they're cheap to fix and the fix carries forward to every storefront the template produces.
SEO gaps
1. Missing <link rel="canonical">
Every indexable page should declare its canonical URL. Without it, URL-parameter variants (?utm_*, ?ref=…, trailing-slash vs not) and tracking-stripped copies fragment a page's authority across multiple URLs in the crawler's view.
Set it on the Metadata API in app/layout.tsx for site-wide defaults, then override per-page where needed:
export async function generateMetadata(): Promise<Metadata> {
const siteUrl = await getSiteUrl();
return {
metadataBase: new URL(siteUrl),
alternates: { canonical: "/" },
// …
};
}export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
return { alternates: { canonical: `/products/${slug}` } };
}Canonicals on /shop and every /categories/[slug] route are the highest-priority ones; they're the pages that accumulate query-parameter variants from ad campaigns.
2. No explicit robots meta
Templates ship robots.txt but no <meta name="robots">. robots.txt is a crawl directive; <meta name="robots"> is an indexing directive. Search engines treat the absence of one as "you may index" but verifying with an explicit signal is the cheap thing to do, and lets you flip individual pages to noindex (cart, account, 404s) without touching robots.txt.
Set the site-wide default in app/layout.tsx:
export async function generateMetadata(): Promise<Metadata> {
return {
robots: { index: true, follow: true },
// …
};
}Override on private routes:
export const metadata: Metadata = { robots: { index: false, follow: false } };3. Favicon should not be a JPG from a CDN
Browsers render the <link rel="icon"> image at 16×16 / 32×32 in the tab strip. JPGs lose to PNG/ICO/SVG at those sizes. There's no alpha channel, no sub-pixel anti-aliasing latitude, and CDN-served JPGs add a third-party DNS lookup to a render-critical resource.
Use Next 16's icon convention: drop app/icon.png (or app/icon.svg) and app/apple-icon.png into the App Router root and Next emits the correct <link> tags with sizes. No metadata config needed:
app/
├── icon.svg ← preferred (scales, tiny payload)
├── icon.png ← fallback for browsers without SVG icon support
└── apple-icon.png ← 180×180, for iOS home-screenIf the merchant wants the icon to be merchant-configurable through brand.ts, expose brand.icon.url and render it via the icons Metadata field, but require PNG/SVG, not JPG:
return {
icons: {
icon: [{ url: brand.icon.url, type: "image/png" }],
apple: brand.icon.appleUrl,
},
};4. hreflang for multi-region targeting
og:locale is read by social previewers; it doesn't tell Google which regional variant of your site to surface for which user. When a storefront targets multiple regions, declare them with <link rel="alternate" hreflang>:
return {
alternates: {
canonical: "/",
languages: {
"en-GH": "https://example.mycimplify.com/",
"en-NG": "https://example.ng.mycimplify.com/",
"x-default": "https://example.mycimplify.com/",
},
},
};Only add this once a second region actually exists. A single-region storefront leaves languages unset. Declaring one variant with no alternates emits noisy and unhelpful tags.
Performance gaps
5. Add preconnect for image CDNs
Templates that preload hero images from a third-party CDN (Cloudinary, Unsplash, etc.) should also preconnect to that origin. Each new origin costs DNS + TCP + TLS handshake on the LCP image, typically 50–150 ms on a warm browser, more on mobile. preload discovers the asset; preconnect warms the socket so the preload doesn't pay the handshake.
CimplifyProvider already preconnects to the Cimplify API and asset CDN origins, so only add manual hints for third-party origins it can't know about.
Two ways to wire it. Either via the Metadata API:
return {
other: {
"link:preconnect:cloudinary": [
'<https://res.cloudinary.com>; rel="preconnect"; crossorigin',
],
},
};…or directly in the layout's <head>, which is more readable and what the templates ship:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://res.cloudinary.com" crossOrigin="" />
<link rel="dns-prefetch" href="https://res.cloudinary.com" />
</head>
<body>{/* … */}</body>
</html>
);
}Rules of thumb:
- One
preconnectper origin that serves a render-critical resource (LCP image, web font, hero video). - Always pair with
dns-prefetchas a fallback for browsers that ignorepreconnect. - The
crossoriginattribute is required for image/font origins served with CORS. Without it the browser warms a separate socket for the actual request. - Cap at ~4 preconnects per page. Each one costs a socket; warming more than the browser will actually use evicts useful ones.
- Drop the preconnect when you drop the origin. Stale
preconnectto a host that no requests go to is wasted handshake budget.
When to skip
- Same-origin assets (
/_next/static/*, assets served from the storefront domain). No new socket; nothing to warm. - Origins discovered late by a third-party script that's itself async-loaded. The preconnect won't matter; the script's own latency dominates.
- Origins used only below-the-fold. Preconnect competes with the LCP image for handshake bandwidth on cold loads.
Responsive images & intrinsic dimensions
The single biggest LCP regression seen on live storefronts is oversized images: a product card displayed at 313×363 served as the 927×1160 Cloudinary original, a 34×34 header logo served as the 1254×1254 original. Each one ships ~50–115 KiB of pixels the browser will downscale and throw away, and shifts layout while it does it.
Two things have to be true on every image:
- The bytes on the wire match the rendered size (within ~2× for retina).
- The browser knows the intrinsic dimensions before the bytes arrive (prevents CLS).
Use <Image>, not <img>
Templates ship a custom next/image loader at lib/cimplify-loader.ts that rewrites Cimplify-hosted asset URLs through the storefront image proxy with w=<requestedWidth>&q=<quality>&f=auto. Using <Image> is what activates that path:
import Image from "next/image";
<Image
src={product.image}
alt={product.title}
width={363}
height={363}
sizes="(min-width: 1024px) 313px, (min-width: 640px) 50vw, 100vw"
className="h-full w-full object-cover"
/>;Raw <img src="..."> bypasses the loader entirely and ships the original asset at whatever resolution it was uploaded. If you've ejected a component that uses <img>, switch it to <Image> before deploying.
Third-party Cloudinary URLs need explicit transforms
The Cimplify loader only rewrites paths under /cimplify/** on res.cloudinary.com (see next.config.ts remotePatterns). If a merchant uploads to a different Cloudinary account (their own dcc5... cloud, say, or the brand-config favicon), the loader's isCimplifyAsset() returns false and <Image> passes the URL through unchanged: original resolution, original encoding.
For those, put the transforms directly in the brand schema. Cloudinary's URL grammar puts transforms between /upload/ and the version:
https://res.cloudinary.com/<cloud>/image/upload/<transforms>/v.../<asset>
^^^^^^^^^^^^
w_400,q_auto,f_auto,c_fillUseful presets:
| Use | Transform string |
|---|---|
| Hero / LCP image | w_1600,q_auto,f_auto |
| Product card | w_600,q_auto,f_auto,c_fill,ar_1:1 |
| Thumbnail / 2× retina of small slot | w_128,q_auto,f_auto |
| Header logo / favicon | w_64,q_auto,f_auto |
f_auto ships AVIF/WebP to browsers that support it (typically 30–60% smaller than JPEG). q_auto lets Cloudinary pick quality per-image; never hardcode q_90 on a CDN that can choose for you.
Then either store the transformed URL in brand.ts / catalogue data, or compose it at render time:
export function cloudinaryUrl(
raw: string,
{ width, quality = "auto", format = "auto" }: { width: number; quality?: string | number; format?: string },
): string {
const transforms = `w_${width},q_${quality},f_${format}`;
return raw.replace("/upload/", `/upload/${transforms}/`);
}<img
src={cloudinaryUrl(brand.icon.url, { width: 64 })}
width={32}
height={32}
alt=""
/>Always declare width and height
The browser uses these to reserve space before the image loads. Without them the layout shifts when the bytes arrive. That's CLS, and Core Web Vitals fails any page over 0.1.
<Image> requires width + height (or fill with a sized parent). Raw <img> doesn't, but you should add them anyway. Even when CSS controls the displayed size, declaring intrinsic dimensions lets the browser compute the aspect ratio and reserve the box:
<img src="..." width={363} height={363} className="h-full w-full object-cover" />Width/height attributes don't override CSS sizing; they only give the browser the aspect ratio.
sizes is what makes responsive srcset work
For <Image> with width/height, Next emits a srcset for several DPRs (1×, 2×, 3×). For <Image fill> or any image whose displayed size depends on the viewport, you need sizes:
<Image
src={product.image}
alt={product.title}
fill
sizes="(min-width: 1024px) 313px, (min-width: 640px) 50vw, 100vw"
className="object-cover"
/>Without sizes, Next assumes 100vw and ships a desktop-width image to mobile.
Lazy-load below the fold, eager-load the LCP
<Image> lazy-loads by default. Override only for the LCP image (typically a hero or first product card):
<Image src={hero.image} alt="" priority fetchPriority="high" sizes="100vw" />priority adds a <link rel="preload"> for the image and disables lazy-loading. Don't sprinkle priority on every image; preloading 8 images contends with critical CSS / JS and slows LCP rather than helping it.
Quick audit
URL="https://<your-handle>.mycimplify.com"
# Any raw <img> tags shipped from a third-party CDN?
curl -s "$URL/" | grep -oE '<img[^>]+src="https://[^"]+"' | head
# Cloudinary URLs without transforms (no /upload/<transform>/ segment)?
curl -s "$URL/" | grep -oE 'res\.cloudinary\.com/[^/]+/image/upload/v[0-9]+/' | sort -uAnything matching the second pattern is a raw original being shipped at full resolution. Fix it at the source (catalogue / brand data) so the bytes-on-the-wire match the rendered size.
What the templates ship today
| Field | Present in cimplify init template? | Notes |
|---|---|---|
metadataBase | yes | derived from getSiteUrl() |
title / description | yes | sourced from brand.name / brand.description |
openGraph | yes | locale, site name |
twitter card | yes | summary_large_image |
| Organization JSON-LD | yes | <OrganizationJsonLd /> in app/layout.tsx |
robots.txt + sitemap.ts | yes | generated from brand |
alternates.canonical | no | add to generateMetadata in app/layout.tsx; override per route |
robots meta | no | add robots: { index: true, follow: true } site-wide |
app/icon.{svg,png} | no (templates fall back to brand.icon.url) | drop a real icon file at the app root |
<link rel="preconnect"> for image CDNs | no | add in <head> of app/layout.tsx |
hreflang alternates | no | only relevant for multi-region storefronts |
<Image> + custom loader | yes | active for /cimplify/** Cloudinary paths; third-party Cloudinary URLs pass through untransformed |
width / height on every image | partial | enforced by <Image>; ejected <img> components often drop them |
Hero priority / fetchPriority | yes (template heroes) | don't add priority to non-LCP images |
Verifying after a change
URL="https://<your-handle>.mycimplify.com"
# Canonical present?
curl -s "$URL/" | grep -i 'rel="canonical"'
# Robots meta present?
curl -s "$URL/" | grep -i 'name="robots"'
# Favicon resolves to a non-JPG?
curl -sI "$URL/icon.svg" | head -1
# Preconnects in the head?
curl -s "$URL/" | grep -i 'rel="preconnect"'For LCP / CLS / INP numbers, run PageSpeed Insights. The curl-level audit only covers headers and static head content, not browser-measured Core Web Vitals.
Where next
- Brand schema: where icon URL, locale, and contact metadata live
- Customizing: eject
app/layout.tsxif you need more head control - Performance & Optimization: rendering model, TTLs, page shape, anti-patterns
Customizing a template
The 95/5 split: ~95% of merchant changes are content (`lib/brand.ts`) and palette (`app/globals.css`). For the remaining 5% (restructured layouts, industry-specific sections, custom selectors), eject the SDK component or extend the brand schema.
API Keys
Cimplify uses three families of credentials: **public keys** for the browser SDK, **secret keys** for server-to-server calls, and **developer keys** for the CLI. Each prefix tells you exactly where it belongs.