Skip to content

Instantly share code, notes, and snippets.

@pontusab
Created April 6, 2025 06:43
Show Gist options
  • Save pontusab/6f10cf651533e3fe466b0c856e1dcf19 to your computer and use it in GitHub Desktop.
Save pontusab/6f10cf651533e3fe466b0c856e1dcf19 to your computer and use it in GitHub Desktop.
Next.js Proximity Prefetch (PPF)
"use client";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
interface ProximityPrefetchProps {
children: ReactNode;
threshold?: number;
predictionInterval?: number;
}
export function ProximityPrefetch({
children,
threshold = 200,
predictionInterval = 0,
}: ProximityPrefetchProps) {
const router = useRouter();
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [prefetchedRoutes, setPrefetchedRoutes] = useState<Set<string>>(
new Set(),
);
const [links, setLinks] = useState<
{ el: HTMLAnchorElement; href: string; rect: DOMRect }[]
>([]);
const updateLinks = useCallback(() => {
const anchors = Array.from(
document.querySelectorAll('a[href^="/"]'),
) as HTMLAnchorElement[];
setLinks(
anchors
.map((el) => {
const href = el.getAttribute("href");
if (href?.startsWith("/") && !href.includes("#")) {
return {
el,
href,
rect: el.getBoundingClientRect(),
};
}
return null;
})
.filter(Boolean) as {
el: HTMLAnchorElement;
href: string;
rect: DOMRect;
}[],
);
}, []);
const calculateDistance = (
x1: number,
y1: number,
x2: number,
y2: number,
) => {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
};
const calculateCenterPoint = (rect: DOMRect) => {
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
};
const prefetchNearbyRoutes = useCallback(async () => {
if (!links.length) return;
// Sort links by proximity to current mouse position
const linksWithDistance = links.map((link) => {
const center = calculateCenterPoint(link.rect);
const distance = calculateDistance(
mousePosition.x,
mousePosition.y,
center.x,
center.y,
);
return { ...link, distance };
});
// Sort by distance
linksWithDistance.sort((a, b) => a.distance - b.distance);
// Prefetch the closest links that are within threshold
const closestLinks = linksWithDistance.filter(
(link) => link.distance < threshold,
);
const routesToPrefetch = closestLinks.map((link) => link.href);
// Prefetch up to 3 routes at a time
for (const route of routesToPrefetch.slice(0, 3)) {
if (!prefetchedRoutes.has(route)) {
console.log("prefetching", route);
router.prefetch(route);
setPrefetchedRoutes((prev) => new Set([...prev, route]));
}
}
}, [links, mousePosition, prefetchedRoutes, router, threshold]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
};
}, []);
useEffect(() => {
// Update links on mount and when DOM changes
updateLinks();
// Set up a MutationObserver to detect new links
const observer = new MutationObserver(() => {
updateLinks();
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["href"],
});
return () => {
observer.disconnect();
};
}, [updateLinks]);
useEffect(() => {
const intervalId = setInterval(() => {
if (mousePosition.x !== 0 || mousePosition.y !== 0) {
prefetchNearbyRoutes();
}
}, predictionInterval);
return () => {
clearInterval(intervalId);
};
}, [mousePosition, prefetchNearbyRoutes, predictionInterval]);
return children;
}
@BaileySimrell
Copy link

This is great ty for sharing @pontusab 🔥 🚀

@dinhkhanh
Copy link

Thank you.

@kurtextrem
Copy link

kurtextrem commented Apr 6, 2025

getBoundingClientRect forces a reflow for any link that is queried on mount / when any element changes (!). I think a more performant solution would be to use IntersectionObservers, where you get the bounding client rect for basically free right after starting observing.

Also worth noting, there is also predictive prefetch (compared to proximity), where e.g. mouse acceleration is taken into account: https://github.com/callumacrae/futurelink / https://github.com/mathisonian/premonish/tree/master. Not saying either approach is strictly better, but worth mentioning.

@pontusab
Copy link
Author

pontusab commented Apr 6, 2025

Yeah tbh a better approach would be to add this directly to a wrapped Link component instead!

@Alexandredc
Copy link

Did you see this PR ? I think it is related
vercel/next.js#77866

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment