Step‑by‑Step Guide to Managing Dynamic In‑Page Links for Single‑Page Apps

When a user lands on a long page and clicks a menu item, they expect the view to jump right where they need it. In a single‑page app (SPA) that expectation can break if you’re not careful with in‑page anchors. That’s why getting dynamic links right matters now more than ever—users are impatient, and search engines love clean navigation.

Why SPAs Need a Different Anchor Strategy

A traditional multi‑page site reloads the whole document when you follow a link. The browser automatically scrolls to the element whose id matches the hash (#section). In an SPA, the page never reloads. Content is often added, removed, or reordered after the initial load, so the hash may point to an element that doesn’t exist yet. If you don’t handle that, the page just sits there, confused.

The Core Pieces

  1. Hash change listener – watches the URL fragment (window.location.hash).
  2. Element registration – a way for components to tell the app “I’m here, my id is X”.
  3. Smooth scrolling logic – optional but makes the experience feel polished.
  4. Accessibility check – focus must move to the target element so screen readers announce it.

Step 1: Set Up a Central Anchor Manager

Create a tiny module that stores a map of ids to DOM nodes. Here’s a plain‑JS example:

// anchorManager.js
const anchors = new Map()

export function registerAnchor(id, element) {
  if (id && element) {
    anchors.set(id, element)
  }
}

export function unregisterAnchor(id) {
  anchors.delete(id)
}

export function getAnchor(id) {
  return anchors.get(id)
}

Every component that renders a section can call registerAnchor in a useEffect (React) or connectedCallback (Web Components). When the component unmounts, call unregisterAnchor to keep the map clean.

Step 2: Listen for Hash Changes

Add a listener once, preferably at the root of your app:

// scrollHandler.js
import { getAnchor } from './anchorManager.js'

function scrollToHash() {
  const hash = window.location.hash.slice(1) // remove the #
  if (!hash) return

  const target = getAnchor(hash)
  if (target) {
    target.scrollIntoView({ behavior: 'smooth', block: 'start' })
    // Move keyboard focus for accessibility
    target.setAttribute('tabindex', '-1')
    target.focus()
  }
}

// Run on load in case the URL already has a hash
window.addEventListener('DOMContentLoaded', scrollToHash)
window.addEventListener('hashchange', scrollToHash)

The scrollIntoView call works in all modern browsers and gives you a smooth scroll without extra libraries.

Step 3: Register Anchors in Your Components

If you’re using React, a tiny hook does the job:

// useAnchor.js
import { useEffect, useRef } from 'react'
import { registerAnchor, unregisterAnchor } from './anchorManager.js'

export function useAnchor(id) {
  const ref = useRef(null)

  useEffect(() => {
    if (ref.current) {
      registerAnchor(id, ref.current)
    }
    return () => {
      unregisterAnchor(id)
    }
  }, [id])

  return ref
}

Then in a component:

function Section({ id, title, children }) {
  const ref = useAnchor(id)

  return (
    <section id={id} ref={ref}>
      <h2>{title}</h2>
      {children}
    </section>
  )
}

The same idea works in Vue, Svelte, or plain JavaScript—just make sure the element is registered after it appears in the DOM.

Step 4: Handle Dynamic Content Loading

Sometimes a section is loaded only after a network request. In that case, the hash may already be in the URL when the data arrives. After you render the new content, call scrollToHash manually:

import { scrollToHash } from './scrollHandler.js'

async function loadSectionData(id) {
  const data = await fetch(`/api/section/${id}`).then(r => r.json())
  // render data...
  // once the DOM node exists:
  scrollToHash()
}

Because the manager already knows about the new element, the scroll will happen instantly.

Step 5: Keep SEO and Accessibility Happy

Even though SPAs rely on JavaScript, search engines still read the static HTML that’s sent initially. Make sure each anchorable section has a real id attribute in the markup. That way a crawler can see the structure without running JS.

For accessibility, always move focus to the target element after scrolling. Adding tabindex="-1" lets you focus an element that isn’t normally focusable (like a <div>). Screen readers will then announce the heading or content inside.

Common Pitfalls and How to Avoid Them

ProblemWhy it HappensFix
Scroll jumps to tophashchange fires before the element is in the DOMDelay the scroll until after registration, or use a setTimeout of 0 as a quick hack
Multiple elements share the same idDuplicate ids break the map lookupEnforce unique ids, perhaps by prefixing with the page name
Focus ring disappearsBrowser removes focus after smooth scrollKeep tabindex="-1" on the target, or add a CSS rule to show focus on programmatic focus

Quick Checklist for Every New SPA Page

  • [ ] Every section that should be reachable has a unique id.
  • [ ] The component registers itself with the anchor manager.
  • [ ] A global hash listener calls scrollIntoView with smooth behavior.
  • [ ] After dynamic loads, invoke the scroll function again.
  • [ ] Focus is moved to the target element for screen readers.

Follow this checklist and you’ll have in‑page navigation that feels as fast as a native app, works for assistive tech, and stays SEO‑friendly.


Reactions