cimplify
Templates

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

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:

app/layout.tsx
export async function generateMetadata(): Promise<Metadata> {
  const siteUrl = await getSiteUrl();
  return {
    metadataBase: new URL(siteUrl),
    alternates: { canonical: "/" },
    // …
  };
}
app/products/[slug]/page.tsx
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:

app/layout.tsx
export async function generateMetadata(): Promise<Metadata> {
  return {
    robots: { index: true, follow: true },
    // …
  };
}

Override on private routes:

app/cart/page.tsx
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-screen

If 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:

app/layout.tsx
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>:

app/layout.tsx
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:

app/layout.tsx
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:

app/layout.tsx
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 preconnect per origin that serves a render-critical resource (LCP image, web font, hero video).
  • Always pair with dns-prefetch as a fallback for browsers that ignore preconnect.
  • The crossorigin attribute 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 preconnect to 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:

  1. The bytes on the wire match the rendered size (within ~2× for retina).
  2. 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:

components/product-card.tsx
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_fill

Useful presets:

UseTransform string
Hero / LCP imagew_1600,q_auto,f_auto
Product cardw_600,q_auto,f_auto,c_fill,ar_1:1
Thumbnail / 2× retina of small slotw_128,q_auto,f_auto
Header logo / faviconw_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:

lib/cloudinary.ts
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 -u

Anything 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

FieldPresent in cimplify init template?Notes
metadataBaseyesderived from getSiteUrl()
title / descriptionyessourced from brand.name / brand.description
openGraphyeslocale, site name
twitter cardyessummary_large_image
Organization JSON-LDyes<OrganizationJsonLd /> in app/layout.tsx
robots.txt + sitemap.tsyesgenerated from brand
alternates.canonicalnoadd to generateMetadata in app/layout.tsx; override per route
robots metanoadd 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 CDNsnoadd in <head> of app/layout.tsx
hreflang alternatesnoonly relevant for multi-region storefronts
<Image> + custom loaderyesactive for /cimplify/** Cloudinary paths; third-party Cloudinary URLs pass through untransformed
width / height on every imagepartialenforced by <Image>; ejected <img> components often drop them
Hero priority / fetchPriorityyes (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

On this page