Written for Angular 22.1 (July 29, 2026). Angular Universal is no longer the current name: SSR and prerendering now live in the @angular/ssr package, added with ng add @angular/ssr, with per-route render modes configured through server routes.
Your Angular app loads fine in Chrome. Navigation works, the data shows up, the design looks sharp. Then you open Google Search Console and find 400 URLs sitting in “Discovered – currently not indexed,” a handful in “Crawled – currently not indexed,” and organic traffic that never got off the floor.
You run the URL Inspection tool, click “View tested page,” and the rendered HTML shows an <app-root> element with almost nothing inside it. Every page shares the same title and meta description because they all inherit them from index.html. None of that is a content or keyword problem, and no amount of copy rewrites or keyword retargeting will fix it.
What you’re looking at is an architecture problem wearing an SEO costume: a client-side-only render mode, a route guard that redirects with a 200 status, a hash-based URL, or a lazy-loaded chunk that never resolves for the crawler. Fix the rendering and routing decisions behind each symptom, and the indexing numbers move. Add another meta tag, and they won’t.
In this guide:
- Diagnose the rendering failure before changing SEO settings
- Choose the right render mode for each route
- Make public routes easy to discover and canonicalize
- Fix missing, duplicate, and incorrect page metadata
- Reduce render blocking and improve page experience
- Verify the fixes and monitor indexing coverage
- Frequently asked questions
- Keep the fix from becoming a recurring bug
Diagnose the Rendering Failure Before Changing SEO Settings
Before you touch a single meta tag, find out what Googlebot actually receives. Almost every Angular indexing issue traces back to a gap between the HTML your server returns and the DOM your browser builds.
Google Search Central describes indexing of JavaScript sites in three phases: crawling, rendering, and indexing. Googlebot fetches the URL, queues the page for rendering in a headless Chromium instance (the Web Rendering Service), then indexes what that render produces.
Google’s documentation doesn’t give a fixed duration for this queue, only that a page “may stay on this queue for a few seconds, but it can take longer than that.” An independent study by Vercel and MERJ, analyzing over 37,000 matched Googlebot crawl-and-render pairs, found a median render delay of about 10 seconds, with most pages completing within minutes, though the tail stretches to roughly 3 hours at the 90th percentile and 18 hours at the 99th.
So the old “second wave takes weeks” framing is largely outdated for well-maintained sites, but the queue is not instant and is not guaranteed. Anything that fails during rendering (a timed-out API call, a blocked script, a JS error) produces an empty or partial page in the index, no matter how fast the queue moves.
That is the root cause of most Angular SEO issues. A client-side-only Angular app returns an index.html containing <app-root></app-root>, a few script tags, and nothing else. If rendering succeeds, the content appears. If it does not, Google indexes an empty shell.
Map Search Console symptoms to likely root causes
This table is the fastest way to move from symptom to fix. Match what you see, then jump to the section that covers it.
| Symptom you can check | Most likely cause | Concrete fix |
| Search Console: “Discovered – currently not indexed” on many URLs | Google found the URLs but has not prioritized rendering them; thin or empty initial HTML lowers priority | Serve real HTML for those routes with @angular/ssr (server or prerender render mode), then resubmit the sitemap |
| Search Console: “Crawled – currently not indexed” | Google rendered the page and judged it near-empty or duplicate | Compare view-source against the rendered DOM; if content only appears after client-side fetch, move that fetch server-side and use transfer state |
| URL Inspection “Tested Live URL” shows an empty <app-root> | Render failed: JS error, blocked resource, or an API call that never resolves during render | Read the “Page resources” and “JavaScript console messages” tabs in the test result, fix the failing resource or error |
| Every page has the same title and meta description | Metadata is hardcoded in index.html and never overridden per route | Set unique values with the Title and Meta services from @angular/platform-browser in each route’s component or a resolver |
| Canonical tag points to the previous URL after in-app navigation | The canonical link element is set once and never updated on route change | Update the canonical inside a router event subscription, and render it server-side so bots see the right value on first fetch |
| Pages only reachable through in-app clicks never get crawled | Navigation uses click handlers or router.navigate() instead of real <a href> links | Replace with anchor tags using routerLink, which renders a crawlable href |
| URLs contain # (for example /#/pricing) | HashLocationStrategy is active | Switch to PathLocationStrategy (the Angular default) and add a server rewrite to index.html |
| Deleted pages return 200 with a “not found” message | The 404 is a client-side view; the server returns success | Return an actual 404 status from server.ts for unknown routes |
| Rich Results Test finds no structured data | JSON-LD is injected client-side after the render window, or not at all | Inject JSON-LD server-side per route so it appears in the initial HTML response |
Compare the initial HTML response with the rendered DOM
Run this comparison on three URL types: your homepage, a marketing or blog page, and a dynamic detail page (product, profile, listing).
- Run curl -A “Googlebot” https://your-site.example/pricing and read the raw HTML.
- Search that output for your <h1> text, your meta description, and your canonical tag.
- Open the same URL in Chrome, then use DevTools “Elements” to see the rendered DOM.
- Note every element present in the DOM but missing from the curl output.
Anything in that gap is content only visible if rendering succeeds. That is your crawl risk list. If your <h1> and body copy live in the gap, no meta-tag change will fix your indexing.
Identify client-side redirects, errors, and empty app shells
Three failures produce a technically valid page with no useful content:
- Client-side redirects with a 200 status. A route guard checks auth state, then calls router.navigate([‘/login’]). The server already returned 200 for the original URL, so Google indexes it as a real page containing a login form.
- Silent runtime errors. A null reference during bootstrap stops the app before content renders. The browser recovers on retry; the crawler does not.
- Data fetched only in the browser. Content loaded in ngOnInit from an API that is slow, rate-limited, or blocked by CORS for the rendering client leaves the shell empty. If you’re not sure which lifecycle hook should own a given piece of startup logic, our guide to Angular lifecycle hooks covers the constructor-versus-ngOnInit boundary that causes most of these race conditions.
Check each by disabling JavaScript in Chrome DevTools and reloading. Whatever disappears is what your initial HTML is missing.
Choose the Right Render Mode for Each Route
Angular 17 replaced the old @nguniversal/express-engine package with @angular/ssr. Angular 19 added server routing with explicit render modes, and current Angular releases keep that model. You install it once:
For the full Angular 22 feature list, including zoneless defaults and Signal Forms, see Angular Latest Version.
That scaffolds a server.ts Express server, a server-side app config, and build targets for SSR and prerendering. The important part is that render mode is a per-route decision, not an app-wide one. In app.routes.server.ts, you assign each path a RenderMode:
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: 'pricing', renderMode: RenderMode.Prerender },
{ path: 'blog/:slug', renderMode: RenderMode.Server },
{ path: 'dashboard/**', renderMode: RenderMode.Client },
];
RenderMode.Prerender builds static HTML at build time. RenderMode.Server renders on request. RenderMode.Client ships the shell and lets the browser do the work. Sound Angular website SEO comes from matching each route to the mode that fits it, not from picking one strategy for everything.
If your team is also weighing Next.js for part of the stack, the same tradeoff shows up there under different names; see our breakdown of Next.js rendering strategies for SEO.
Use server-side rendering for frequently changing public pages
Pick RenderMode.Server when the content changes often and the URL set is large or unpredictable: blog posts pulled from a CMS, search result pages, marketplace listings, job pages.
The crawler gets complete HTML on the first fetch with no dependency on the render queue. First paint improves too, since content arrives in the response instead of after a bundle download.
The tradeoff is real: you need a Node server running, which means hosting cost, cold starts on serverless platforms, and a caching layer if traffic is high. You also need to avoid hydration mismatches.
If the server renders one thing and the client renders another (a timestamp, a random ID, a window check), Angular logs a hydration error and may discard the server DOM. Guard browser-only code with isPlatformBrowser() and use TransferState so the client reuses server-fetched data instead of refetching it.
Prerender stable marketing and editorial routes
For pages that change on a deploy cadence, not a per-request cadence, RenderMode.Prerender is the cheaper answer. Homepage, pricing, about, feature pages, documentation, static landing pages: these become plain HTML files served from a CDN.
For parameterized routes, supply the list with getPrerenderParams:
{
path: 'guides/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const slugs = await fetchGuideSlugs();
return slugs.map(slug => ({ slug }));
},
}
No server runtime, no cold start, no hydration risk from request-time data. Angular’s own docs warn that generating a very large number of prerendered documents increases deploy size and build time, so cap this at route sets you can count. Older tools like Scully or Prerender.io filled this gap before native support existed; with @angular/ssr in place, you usually do not need them.
Keep authenticated and highly interactive views client-rendered
Not everything should render on the server. Set RenderMode.Client for:
- Dashboards, account settings, billing, and anything behind login
- Heavy interactive tools (editors, canvases, real-time views)
- Admin panels
These pages should never be indexed anyway. Rendering them on the server burns compute and adds session-handling complexity for zero SEO gain. Pair client mode with <meta name=”robots” content=”noindex”> on those routes and a Disallow in robots.txt for the path prefix, so crawl budget goes to pages that can actually rank.
Make Public Routes Easy to Discover and Canonicalize
Rendering solves what a bot sees on a page, while routing solves whether a bot reaches the page at all.
Replace hash-based URLs with crawlable path routes
If your URLs look like example.com/#/features, you are using HashLocationStrategy. Google Search Central still advises against fragment-based URLs for content because the fragment identifier isn’t sent to the server, and Google treats # URLs as pointing to the same underlying page. In practice, /#/features and /#/pricing compete as the same URL.
Angular defaults to PathLocationStrategy, so the fix is usually removing the config you added:
provideRouter(routes, withHashLocation()) // remove this
Then configure your server or CDN to rewrite unmatched paths to index.html so deep links return 200 instead of 404. After the switch, add 301 redirects from old hash URLs where you can, and update internal links.
Fix broken internal links and lazy-loaded route access
Two Angular-specific link problems block discovery:
- Buttons instead of anchors. A <div (click)=”goToPricing()”> produces no href. Googlebot has nothing to follow. Use <a routerLink=”/pricing”>, which renders a real anchor.
- Lazy chunks that stall. loadChildren splits routes into separate bundles. If a chunk is slow, cached badly, or fails to fetch during rendering, the route renders empty. This is a common source of Angular JS SEO failures because it only shows up under crawler conditions, not on your dev machine.
Three ways to reduce that risk:
- Prerender or server-render the lazy routes that matter for search, so their content lands in the initial HTML
- Set PreloadAllModules (or a selective preload strategy) so critical chunks load early
- Confirm chunk files return 200 with correct MIME types and long-lived cache headers, and are not blocked in robots.txt
Route guards deserve their own check. A guard that silently calls router.navigate([‘/login’]) leaves the original URL returning 200 with login content indexed against it. For public routes that moved, issue a real 301 from server.ts. For gone content, return a genuine 404.
Publish accurate sitemap and robots directives
Generate sitemap.xml from the same source of truth your router uses, ideally as a build step, so the two never drift. Include only canonical, indexable, 200-status URLs. Exclude /login, /dashboard, filtered variants, and anything marked noindex.
One practical difference from Next.js worth noting if your team works across frameworks: Angular has no built-in sitemap or robots.txt convention. Next.js ships sitemap.ts and robots.ts file conventions that generate these automatically. With Angular, you generate the sitemap as a build step (a Node script, a CMS integration, or a CI task) and place robots.txt in your src/ directory so the Angular CLI copies it to the dist root at build time. Neither is wrong, as Angular just doesn’t prescribe the approach. The important thing is that your sitemap and your router use the same source of truth for what URLs exist.
In robots.txt, never block your JS or CSS directories. Google’s documentation is explicit: if the crawler cannot fetch the resources needed to render, it cannot see your content. Blocking /assets/ or a bundle path is a fast way to turn a working page into an empty one.
Prevent duplicate pages with canonical tags
Angular apps generate duplicates easily: trailing slash variants, uppercase paths, query strings from campaign tags, and filter or pagination parameters that produce near-identical content.
Set one self-referencing canonical per indexable route, rendered server-side. Do not append a canonical client-side only, because if the render fails, the tag never exists. For paginated lists, canonical each page to itself, not to page one. For filtered views with no unique value, either noindex them or canonical them to the unfiltered URL.
International Angular apps: if you’re using @angular/localize for internationalization, each locale generates a separate build output at a distinct URL prefix (typically /en/, /fr/, /de/). Add hreflang link elements in your server-rendered <head> for each alternate locale pointing to the same logical page; without these, Google may treat your translated pages as duplicate content rather than locale variants. The canonical for each locale page should point to that locale’s own URL, not to the default-language version.
Fix Missing, Duplicate, and Incorrect Page Metadata
The default Angular failure here is simple: index.html has one <title> and one meta description, and every route inherits them. Google sees fifty pages with the same title, picks its own snippet, and your click-through rate suffers.
Set unique titles and meta descriptions for every indexable route
The Title and Meta services from @angular/platform-browser remain the current, documented way to manage this. Angular also supports a title property directly on route definitions, which the router resolves automatically.
For static routes, the route-level title is the cleanest option:
{ path: 'pricing', component: PricingComponent, title: 'Pricing | Example' }
For dynamic routes, set both title and description from loaded data:
import { Component, OnInit, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
@Component({
selector: 'app-post',
templateUrl: './post.component.html',
})
export class PostComponent implements OnInit {
private title = inject(Title);
private meta = inject(Meta);
ngOnInit(): void {
this.title.setTitle(`${this.post.headline} | Example Blog`);
this.meta.updateTag({ name: 'description', content: this.post.summary });
}
}
Use updateTag, not addTag. addTag appends a second description tag on every navigation.
A practical audit shortcut: point an AI coding assistant at your routes file and components with a prompt like “list every route in this Angular app that has no title property and whose component never calls Title.setTitle or Meta.updateTag.” On a 60-route app, this takes a minute and gives you an exact gap list to work through.
Render dynamic metadata in the initial response
Metadata set in ngOnInit only reaches the HTML source if the route renders on the server or at build time. On a client-rendered route, view-source still shows the index.html defaults, and most social crawlers (Slack, LinkedIn, X) don’t execute JavaScript, so link previews break.
Check it directly: curl https://your-site.example/blog/some-post | grep -i “<title>”. If you see your generic site title instead of the post headline, you need RenderMode.Server or RenderMode.Prerender.
Add Canonical and Open Graph tags without duplicates
Canonical links are <link> elements, not meta tags, so Meta will not manage them. Use DOCUMENT and query for an existing tag before creating one:
private doc = inject(DOCUMENT);
setCanonical(url: string): void {
let link = this.doc.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = this.doc.createElement('link');
link.setAttribute('rel', 'canonical');
this.doc.head.appendChild(link);
}
link.setAttribute('href', url);
}
For Open Graph, og:title, og:description, og:image, and og:url all use the property attribute, so update them with this.meta.updateTag({ property: ‘og:title’, content: value }). Passing name instead of property creates a second, ignored tag.
Implement schema markup that matches visible content
Add JSON-LD per route as a <script type=”application/ld+json”> element in the head, rendered server-side. Match the type to the page: Article for posts, Product for product pages, FAQPage only when the questions and answers are actually visible on the page.
Google’s structured data policies require that markup describe content users can see. Marking up reviews or FAQs that do not appear on the page can trigger a manual action. Validate every template with the Rich Results Test before shipping, and remove any old tag before injecting a new one during client-side navigation.
Reduce render blocking and improve page experience
Once bots can see your pages, speed affects how efficiently they crawl and how well pages perform for users. Angular apps tend to fail on bundle size first.
Cut JavaScript sent during the initial load
Start with a build report:
ng build –configuration production –stats-json
npx webpack-bundle-analyzer dist/your-app/stats.json
Common wins in that report:
- A full charting or date library imported for one component
- Moment.js still present when date-fns or the Intl API would do
- A UI kit imported wholesale instead of by component
- Duplicated dependencies from mismatched versions
Set budgets in angular.json so regressions fail the build instead of shipping quietly. Enable SSR with hydration so the browser reuses the server DOM instead of re-creating it, which cuts the work between HTML arriving and the page becoming interactive.
Incremental hydration (withIncrementalHydration()) is the performance lever most directly relevant to Core Web Vitals here. By leaving components dehydrated until their trigger fires (viewport, interaction, or idle), it reduces the JavaScript parsed and executed on initial load without sacrificing server-rendered HTML for the crawler.
The bundle savings compound with @defer; each deferred block’s dependencies aren’t downloaded until needed, which directly reduces Time to Interactive and INP on content-heavy routes.
If your project was generated with Angular 21 or later, Zone.js is excluded by default (ng new now omits it). Zoneless apps skip Zone.js’s change detection patching entirely, which reduces initial bundle size and avoids the overhead of Zone.js intercepting every async browser API.
For existing apps, the onpush_zoneless_migration schematic automates the conversion. The performance benefit for SEO is indirect (smaller bundles mean faster Time to Interactive), but it compounds with incremental hydration and @defer.
Use lazy loading without hiding indexable content
Lazy loading is correct for feature areas and wrong for above-the-fold content on indexable pages. Two rules keep the two apart:
- Anything that belongs in a page’s indexed content should render in the initial HTML, not behind a deferred block or a client-side fetch.
- Anything below the fold or interaction-triggered can defer.
Angular’s @defer block is the right tool for the second case:
@defer (on viewport) {
<app-related-posts />
} @placeholder {
<div class="skeleton"></div>
}
Never wrap your <h1>, primary copy, or main product details in @defer (on interaction). Google doesn’t click, scroll, or hover, so that content won’t appear in the indexed version.
Incremental hydration keeps deferred content indexable:
The risk with @defer for SEO has always been that deferred content doesn’t appear in the initial HTML. Incremental hydration, stable since Angular v20, solves this. Enable it once in your app config:
// app.config.ts
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withIncrementalHydration())
]
};
Then add a hydrate trigger to any @defer block whose content should be indexable:
@defer (on viewport; hydrate on viewport) {
<app-product-reviews />
} @placeholder {
<div class="skeleton"></div>
}
With this pattern, Angular renders the component’s main content on the server (so it appears in the initial HTML and is crawlable), then hydrates it client-side only when the trigger fires. The placeholder is what the browser shows before hydration; not what the crawler sees.
Incremental hydration also automatically enables event replay, so any user interactions (clicks, hovers) that happen before hydration completes are queued and replayed once the component is live. If you had withEventReplay() in your providers, you can remove it — withIncrementalHydration() includes it.
The SEO implication: you can now safely @defer above-the-fold components that contain indexable content, as long as you add the hydrate trigger. The crawler receives complete HTML; the bundle savings are real.
Optimize images, fonts, and third-party scripts
Use NgOptimizedImage from @angular/common. It sets sizing attributes, adds loading=”lazy” to below-the-fold images, and lets you mark the LCP image with priority so it preloads:
<img ngSrc=”//cdn-employer-wp.arc.dev/assets/hero.webp” width=”1200″ height=”630″ priority alt=”Product dashboard” />
Serve WebP or AVIF, always set explicit width and height to prevent layout shift, and self-host fonts with font-display: swap instead of blocking on a third-party stylesheet.
Third-party tags are the quiet killer. Chat widgets, session recorders, and A/B testing scripts often load synchronously and delay rendering. Load them after the main content, or gate them behind consent, and re-measure after each removal.
Measure core web vitals on real route types
One Lighthouse run on the homepage tells you very little. Test at least four route types: homepage, a prerendered marketing page, a server-rendered dynamic page, and a client-rendered app view.
Use Lighthouse and PageSpeed Insights for lab data, but weight the Core Web Vitals field data in Search Console more heavily, since it reflects real devices and networks. Watch LCP on content routes (usually a hero image or the first paragraph block) and INP on interactive routes.
Current Core Web Vitals thresholds (as of 2026):
| Metric | Good | Needs improvement | Poor |
| LCP (Largest Contentful Paint) | ≤ 2.5s | 2.5s–4.0s | > 4.0s |
| INP (Interaction to Next Paint) | ≤ 200ms | 200ms–500ms | > 500ms |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | 0.1–0.25 | > 0.25 |
INP replaced FID (First Input Delay) as a Core Web Vitals metric in March 2024. If you’re looking at older audits or blog posts referencing FID, those numbers no longer affect your ranking signal.
For INP specifically, withEventReplay() (automatically included when you enable incremental hydration) captures browser events that fire before a component has hydrated and replays them once hydration completes. Without it, a user click during the hydration window is silently dropped, which registers as a slow interaction in INP measurement even though the page felt responsive.
On interactive routes with SSR, this is often the difference between a “Needs Improvement” and a “Good” INP score. Check mobile separately: mobile-first indexing means Google evaluates the mobile render.
Verify the Fixes and Monitor Indexing Coverage
Shipping a fix and assuming it worked is how indexing problems come back. Use a repeatable check before release and a monitoring routine after.
Test Source HTML, Status Codes, and Canonicals before release
Run this on staging for every route type before you deploy:
- curl -sI https://staging.example/pricing and confirm a 200 status.
- curl -s https://staging.example/pricing and confirm the <h1>, body copy, unique <title>, meta description, canonical, and JSON-LD are all present in the raw HTML.
- Load the same URL with JavaScript disabled in Chrome and confirm the main content still shows.
- curl -sI https://staging.example/this-page-does-not-exist and confirm a real 404, not a 200 with a client-side error view.
- Paste the rendered HTML into the Rich Results Test and confirm the structured data validates.
- Confirm robots.txt does not block your JS, CSS, or asset directories.
Wire steps 1, 2, and 4 into CI as a smoke test against a handful of representative URLs. Metadata regressions are easy to introduce during a refactor and nearly invisible in code review.
Use Search Console to inspect URLs and track coverage changes
After deploy, use URL Inspection on live URLs, not the cached index version:
- Enter the URL, click Test Live URL, then View tested page
- Read the HTML tab: this is what Googlebot’s renderer produced
- Read Page resources for anything that failed to load
- Read JavaScript console messages for runtime errors during render
- Click Request Indexing once the render looks correct
Then watch the Pages report over the next two to four weeks. You want “Discovered – currently not indexed” and “Crawled – currently not indexed” counts to fall and valid pages to rise. Add Bing Webmaster Tools too; Bing’s crawler handles JavaScript less aggressively, so it often exposes rendering gaps sooner than Google does.
Timelines vary with crawl budget and site size. A small site may see coverage shift in days; a large one takes weeks.
Audit organic landing pages with crawl and analytics tools
Run a crawl with Screaming Frog (enable JavaScript rendering) or an Ahrefs or Semrush site audit, and compare the crawled URL list against your sitemap. Investigate anything in the sitemap the crawler never reached, and anything the crawler found that is not in your sitemap.
In Google Analytics, filter organic landing pages and compare against your indexable route list. Routes that get impressions in Search Console but no clicks usually have a metadata problem. Routes with zero impressions usually have a rendering or discovery problem.
Frequently Asked Questions
Why isn’t my Angular app showing up in Google search results?
The most common cause is a client-side-only render mode that sends Google an empty <app-root> shell instead of real content. Check the rendered HTML in Search Console’s URL Inspection tool: if the “Tested Live URL” view shows little or no content, Google is indexing an empty page, not your actual site. The fix is almost always a rendering change (server-side rendering or prerendering for that route), not a metadata or keyword change.
Is Angular bad for SEO?
No, but a client-side-rendered Angular app with no server-side rendering is. Angular itself ships an official @angular/ssr package specifically to solve this, with per-route control over whether a page renders on the server, at build time, or in the browser. Teams that assign the right render mode to each route see no inherent SEO disadvantage compared to a server-rendered framework.
Does Google render JavaScript for SEO?
Yes. Google crawls your page’s raw HTML first, then queues it for a separate rendering pass in a headless Chromium browser before indexing the result. Google’s own documentation doesn’t give a fixed time for that queue, but independent measurement puts the typical delay at well under a minute for most sites, with a longer tail for lower-priority or lower-quality pages.
The real risk isn’t the delay; it’s anything that fails during that render (a stalled API call, a JavaScript error, a blocked script), which leaves Google with an empty page regardless of how fast the queue moves.
Why does my Angular site show “Discovered – currently not indexed” in Search Console?
This usually means Google found the URL but hasn’t prioritized rendering it, often because the initial HTML response is thin or empty. The standard fix is to serve real, complete HTML for that route (through server-side rendering or prerendering) and resubmit the sitemap.
Should I use server-side rendering or prerendering for Angular SEO?
It depends on how often the content changes. Prerendering fits pages that update on a deploy cadence, not per request, such as a homepage, pricing page, or documentation. Server-side rendering fits pages with frequently changing or unpredictable content, like a blog fed by a CMS or search results. Reserve client-side rendering for authenticated or highly interactive views that shouldn’t be indexed at all.
Do hash-based URLs hurt Angular SEO?
Yes. A URL like example.com/#/pricing uses HashLocationStrategy, and the fragment after the # is never sent to the server, so Google treats every hash variant of a URL as the same page. Angular defaults to path-based routing (PathLocationStrategy), so fixing this usually means removing hash-routing configuration rather than adding anything new.
Why do all my Angular pages have the same title and meta description in Google?
Because the metadata is set once in index.html and every route inherits it by default. Angular’s Title and Meta services, or a route-level title property, let you set unique values per route. Without one of those, Google sees dozens of pages with identical titles and picks its own snippet instead of yours.
Keep the Fix from Becoming a Recurring Bug
Fixing this once is straightforward: pick the right render mode, correct the routing, ship the metadata, and confirm it in Search Console. The harder part is what happens after the fix ships.
A team that adds the next route without asking which render mode it needs will reintroduce the same failure, and Search Console will show the same “Discovered – currently not indexed” pattern you just spent a week clearing.
If your Angular app is losing organic traffic to indexing gaps, you need engineers who treat rendering strategy as part of the architecture decision, the same way they’d approach Angular state management decisions.
Arc pre-vets Angular developers for technical depth and English fluency before you see a profile. HireAI matches your requirements against a pool of vetted candidates and returns a shortlist in minutes, not weeks.








