seo-optimization
Audit and improve SEO for the Angular customer-web app in the monorepo — Title/Meta service usage, structured data, sitemaps, Open Graph, Core Web Vitals, and local-business schema. Use this whenever
Files1
View on GitHubSKILL.md.claude/skills/seo-optimization/SKILL.md
GitHubSEO Optimization — Angular (customer-web)
Scope: apps/frontend/customer-web (Angular standalone components, Tailwind v4). Does not apply to admin-web (not indexed, behind auth) unless explicitly asked.
Read this first: customer-web is CSR-only today
As of this writing, customer-web has no @angular/ssr, no platform-server, no server.ts — it's a client-side-rendered SPA shipping one static index.html for every route. This caps what's achievable here in a way Next.js's App Router (the sibling template's target) doesn't have to contend with:
- Open Graph/Twitter card tags cannot vary per route. Facebook, X/Twitter, LinkedIn, and Slack's link-preview crawlers do not execute JavaScript — they read the static
index.htmlresponse as-is. A CSR app can only ship one fixed set of OG tags for the whole site; a shared link to any inner page previews with the homepage's title/image, not that page's own. - JSON-LD and per-route
<title>/<meta description>injected at runtime are invisible to non-JS-executing crawlers and are a gamble even for JS-executing ones (Google generally renders JS, but on its own schedule via a second rendering pass — indexing can lag, and rich-result eligibility for JSON-LD is inconsistent for client-injected data). - The sitemap can't be generated per-request — it has to be a build-time or CI-time static file.
The single highest-leverage recommendation for real SEO here is enabling Angular SSR (ng add @angular/ssr) or at minimum build-time prerendering of the known static routes (Angular's prerender/SSG option, which ng add @angular/ssr also sets up). Say this plainly in any audit before diving into the CSR-workaround checklist below — don't let the workarounds read as "SEO solved" when they're a materially weaker substitute for SSR. If the user explicitly wants to stay CSR-only, the sections below are what's actually achievable; if they're open to SSR, lead with that and this skill's checklist still applies on top of it (SSR fixes the crawler-visibility problem, it doesn't replace metadata/sitemap/structured-data work).
Workflow
- Locate the app and confirm SSR status. Check for
apps/frontend/customer-web/server.tsand an@angular/ssrdependency inpackage.jsonbefore doing anything else — the audit's severity framing depends on this. - Run the audit checklist below against the current codebase — read files, don't assume. Use
grep/rgto find gaps (missingalt, missing per-route title, raw<img>instead ofNgOptimizedImage). - Report findings as a checklist (✅ / ⚠️ / ❌) before making changes, unless the user asked you to just fix everything directly.
- Fix in priority order: SSR/prerender feasibility (flag, don't silently skip) → per-route Title/Meta → sitemap/robots → structured data → Open Graph → images → Core Web Vitals → semantic HTML. Content structure/topical authority (section 8) is editorial — surface as recommendations, don't auto-generate marketing copy.
- Keep changes TypeScript-strict, no
any, minimal comments, standalone components + Signals — consistent withrules/frontend.md.
1. Per-route Title & Meta tags (highest priority within CSR)
Angular's Title and Meta services (@angular/platform-browser) are the equivalent of the Metadata API — but since there's no server render, they must run early and synchronously enough that a JS-executing crawler's first paint already reflects them.
Preferred: a custom TitleStrategy (one central place, not scattered ngOnInit calls per component) so every route's data drives its title/description declaratively:
// src/app/core/seo/route-seo.strategy.ts
import { Injectable, inject } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';
import { TitleStrategy, RouterStateSnapshot } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class RouteSeoStrategy extends TitleStrategy {
private readonly title = inject(Title);
private readonly meta = inject(Meta);
override updateTitle(snapshot: RouterStateSnapshot): void {
const routeTitle = this.buildTitle(snapshot) ?? 'Business Name';
this.title.setTitle(`${routeTitle} | Business Name`);
const description = this.deepestChild(snapshot)?.data['description'] as string | undefined;
if (description) {
this.meta.updateTag({ name: 'description', content: description });
}
const canonical = `${window.location.origin}${snapshot.url}`;
this.meta.updateTag({ rel: 'canonical', href: canonical } as never);
}
private deepestChild(snapshot: RouterStateSnapshot) {
let route = snapshot.root;
while (route.firstChild) route = route.firstChild;
return route;
}
}
Register it in app.config.ts: { provide: TitleStrategy, useClass: RouteSeoStrategy }. Each route then carries data: { title: 'Services', description: '...' }.
Checklist:
- ⚠️ Every route has a unique
title/descriptionin its routedata— no duplicates across routes. - ⚠️
<meta name="description">exists as a real DOM node with correct content when the app is fully rendered — check with a headless browser or "View Rendered Source" in devtools, not View Source (which shows the pre-JS static shell and will never reflect this). - ❌ Never leave the canonical
<link>unset — for a CSR app all routes otherwise share the staticindex.html's implicit canonical, which is wrong for every route but the homepage.
2. Sitemap & robots.txt (build-time static files)
No sitemap.ts/robots.ts file-convention API exists here — these are plain static assets served from Angular's public assets directory (public/ in newer Angular CLI layouts, src/assets in older ones — check angular.json's assets/root config for this project's actual layout).
Static routes: hand-maintain public/robots.txt and a static public/sitemap.xml for fixed pages (home, services, contact).
Dynamic routes (service/listing/blog detail pages pulled from customer-api): a static file can't reflect the DB, so generate sitemap.xml at build/CI time with a small script that calls the API and writes the file into the build output before/after ng build — mirroring the pattern this repo already uses for docs:export/docs:site (dev-ops/scripts/*.ts run via tsx, wired into a root package.json script). Do not hand-roll this inside a component; it has no runtime DOM to run in on a CSR app anyway.
public/robots.txt:
User-agent: *
Allow: /
Sitemap: https://example.co.za/sitemap.xml
Checklist:
- ❌ Never let a non-production build's
robots.txt(if a separate one exists) leak into a production deploy — check the Angular build'sfileReplacements/asset config per environment (seeCLAUDE.md's known gap:environment.prod.tsisn't currently wired viafileReplacements— the same gap likely affects any environment-specific static asset swap, so verify robots.txt truly differs per environment before trusting it does). - ⚠️ Dynamic routes come from the DB via the generation script, not hardcoded — otherwise the sitemap goes stale.
3. Structured data (JSON-LD)
Almost every customer-facing app in this portfolio is a local service business — LocalBusiness schema is high-value and often skipped. On a CSR app this has to be injected into document.head at runtime via a service (there is no server render to embed it in the initial HTML):
// src/app/core/seo/json-ld.service.ts
import { Injectable, inject, DOCUMENT } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class JsonLdService {
private readonly document = inject(DOCUMENT);
setLocalBusiness(): void {
const data = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'Business Name',
image: 'https://example.co.za/logo.png',
telephone: '+27...',
address: {
'@type': 'PostalAddress',
streetAddress: '...',
addressLocality: '...',
addressRegion: 'KZN',
postalCode: '....',
addressCountry: 'ZA',
},
areaServed: 'Durban, KZN',
priceRange: '$$',
};
const script = this.document.createElement('script');
script.type = 'application/ld+json';
script.text = JSON.stringify(data);
this.document.head.appendChild(script);
}
}
Call this once (e.g. in app.component.ts's constructor, or per-route for Service/Article schema on detail pages — remove the previous route's script on navigation so schemas don't stack).
Other schema types worth checking for by page type:
- Booking/quote flow →
Service+Offer - Reviews shown on-page →
AggregateRating(only if reviews are real and displayed — never fabricate ratings, this violates Google's structured data guidelines) - Blog/content pages →
ArticleorBlogPosting
Checklist:
- ❌ Do not claim "JSON-LD is crawler-visible" the way the SSR version of this checklist would — client-injected JSON-LD is a best-effort measure on a CSR app, not a guarantee. Say so in the audit.
- ⚠️ Validate with Google's Rich Results Test (it renders JS, so client-injected JSON-LD will show up there even though that doesn't guarantee indexing behavior matches).
4. Open Graph & Twitter Cards — the sharpest CSR limitation
Because OG/Twitter tags live in the static index.html and social crawlers don't execute JS, a CSR Angular app effectively has one shared OG image/title/description for the entire site, set directly in apps/frontend/customer-web/src/index.html:
<meta property="og:title" content="Business Name — Short Value Prop" />
<meta property="og:description" content="One or two sentences, unique to the site as a whole." />
<meta property="og:image" content="https://example.co.za/og-image.png" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_ZA" />
<meta name="twitter:card" content="summary_large_image" />
There is no per-route equivalent without SSR/prerendering — updating Meta at runtime (section 1) does not help here since the crawler never runs that code. If per-page social previews matter (they usually do for a service business sharing individual service pages), this is the concrete, specific case to point to when recommending SSR.
Checklist:
- ⚠️ OG image is a real static asset (1200×630) in
public/, not a screenshot. - ⚠️
og:localeset toen_ZA(or the target locale) for local businesses. - ❌ Flag explicitly if the user expects per-page social previews — that requires SSR/prerendering, not a
Metaservice call.
5. Images & media
- ⚠️ Every image should use Angular's built-in
NgOptimizedImagedirective (import { NgOptimizedImage } from '@angular/common',<img ngSrc="..." width="…" height="…" priority>for the LCP image) — flag any plain<img>found via grep. - ❌ No missing/empty
altattributes. Decorative images usealt="", never omit it. - ⚠️
priorityset on the Largest Contentful Paint image (usually the hero) so it isn't lazy-loaded; every other image left to lazy-load by default.
6. Core Web Vitals / performance
- ⚠️ Fonts self-hosted (
@font-facepointing at a hashed asset built throughangular.json's asset pipeline), not a render-blocking<link>to Google Fonts — same principle asnext/font, no CLI-generated equivalent, so this is manual. - ⚠️ Route-level lazy loading (
loadComponent/loadChildren) for anything not needed on first paint — checkapp.routes.ts. - ⚠️
provideRouterpreloading strategy is deliberate (PreloadAllModulesfor a small app is fine; a large app should use a custom/quicklink-style strategy) — an unset default means no preloading at all, hurting perceived nav speed without helping initial load. - ⚠️ Bundle budgets configured in
angular.json(budgetsunder thebuildtarget) so a regression fails the build, not just a Lighthouse run. - If Lighthouse/PageSpeed Insights is available, run it and report Core Web Vitals scores before/after — CSR apps typically score worse on LCP/INP than an equivalent SSR app doing the same work, which is expected, not a sign the fix was wrong.
7. Semantic HTML & URL structure
- ⚠️ One
<h1>per route, logical heading order (no skipping levels). - ⚠️ Nav/footer use
<nav>,<footer>,<main>— not generic<div>soup. - ⚠️ Angular Router paths are human-readable and kebab-case (
/services/garden-maintenance, not/services/:idwith a numeric id in the URL). - ⚠️ Trailing slash / www vs non-www consistency is enforced at the hosting/DNS layer (Angular's router doesn't own this on a static SPA) — check the Coolify/Azure Static Web App redirect rules and DNS/CNAME setup so canonical and actual served URL match.
8. Content structure & topical authority
Technical SEO gets a page crawled and indexed (to the extent a CSR app can be). Topical authority is what makes it rank against local competitors who've been publishing longer — this is the part most technical audits skip, and it's independent of the CSR/SSR question.
- Pillar/cluster structure: one broad pillar page per core service category, linking out to narrower cluster pages targeting specific queries. Clusters link back to the pillar; related clusters cross-link.
- Internal links use
routerLink, not a plain<a href>re-navigating the whole app or a JS-only click handler —routerLinkrenders a realhrefin the DOM, which is what makes the link graph crawlable, not just clickable, even before any JS runs. - Location depth, not a thin area list: for local businesses, real per-suburb content ("Garden Services in Umhlanga") outperforms one generic "areas we serve" page. A templated paragraph with the suburb name swapped in counts as thin/duplicate content and can hurt more than help.
- FAQ content +
FAQPageschema: genuine answers to real customer questions. - E-E-A-T (Experience, Expertise, Authoritativeness, Trust): matters most for YMYL content — a medical practice site needs visible practitioner credentials and registration numbers, not just a booking form. Testimonials/reviews must be real and attributable (never fabricate — ties back to the
AggregateRatingwarning in section 3). - Avoid thin pages: a service page with a heading, a stock photo, and a lead-gen form has little for Google to rank on. Flag pages under ~150–200 words of substantive unique copy.
When auditing, call out thin/duplicate content and missing pillar structure as recommendations for the user to action (write copy, define the cluster map) — don't generate marketing copy unprompted.
Reporting format
When auditing, report like this:
## SEO Audit — apps/frontend/customer-web
❌ CSR-only (no @angular/ssr) — OG/Twitter tags, per-route metadata, and JSON-LD are all
working around this limitation rather than solving it; recommend `ng add @angular/ssr`
✅ RouteSeoStrategy wired, per-route title/description present
❌ No sitemap.xml or robots.txt found under public/
⚠️ og:* tags in index.html are generic site-wide (expected on CSR — flagged, not a bug)
⚠️ 4 <img> tags not using NgOptimizedImage (hero.component.html, gallery.component.html)
✅ LocalBusiness JSON-LD present on homepage, missing on /contact
⚠️ No pillar/cluster structure — 6 service pages exist as flat siblings with no cross-linking
❌ /areas-we-serve uses one templated paragraph per suburb — thin/duplicate content risk
Then fix in priority order from the workflow above, confirming scope with the user first if the fix set is large — and lead with the SSR recommendation if it's missing, every time, rather than letting the CSR-workaround fixes read as a complete solution.