Canonicals That Pointed at a Redirect: www vs Apex, Sitemaps and Permanent Redirects in Next.js
Every canonical, og:url and sitemap entry on this site named a URL that 308-redirected. The fix, plus the sitemap, feed and redirect decisions made around it in Next.js.
The defect: canonicals that answered with a redirect
This site is served from www.rukon.dev. A request to the apex, rukon.dev, gets a 308 Permanent Redirect to the www host. That is a normal setup. The problem was that the site built its absolute URLs from the apex, so every canonical link, every og:url and every sitemap entry named a URL that did not answer 200. It answered with a redirect to the real one.
Nothing looked broken. Pages rendered, links worked, a browser followed the redirect without anyone noticing. It surfaced during a content audit in September 2026, which is typical of this class of defect: it only shows up when someone reads the head of a page and then requests the URL it names.
This article is for anyone whose Next.js site answers on two hostnames. It covers the fix and the related URL decisions around the sitemap, the feed and permanent redirects.
One constant for the host that answers 200
A canonical tag is a statement about which URL is the real one. Naming a URL that redirects asks a crawler to follow a hop to find the page you just told it was canonical. The fix was to stop deriving the host from the domain name and name the serving host explicitly:
/**
* The apex 308-redirects to www, so building canonicals, og:url and the
* sitemap from the apex pointed every one of them at a URL that redirects.
*/
canonicalHost: "www.rukon.dev",Everything absolute is built from it. BASE_URL in src/lib/seo.ts is https:// plus that host, metadataBase in the root layout is new URL(BASE_URL), and an absoluteUrl() helper turns site-relative paths into absolute ones for JSON-LD, which wants absolute URLs. Routes then declare canonicals as relative paths, for example alternates: { canonical: "/" }, and Next.js resolves them against metadataBase.
The display domain stayed separate. site.domain is still rukon.dev, used only as text in the share image. Keeping "what we print" and "what we link to" in two fields is what stops the next change from reintroducing the bug.
Canonical is not the deployment URL
The ERTH homepage shows the other side of the same idea. It is deployed on Vercel, but its canonical URL, og:url, robots.txt and sitemap.xml all point at the client's own domain, as the approved design and the requirements specify. Those values are intentionally independent of whatever URL the deployment happens to have, so they are already correct when the custom domain is attached. More on that page's markup is in the ERTH case study.
In both cases the rule is the same: the canonical names the URL you want indexed and that answers 200 there, not the URL the build happened to run on.
A sitemap whose dates mean something
The sitemap is generated by src/app/sitemap.ts from the same data every page reads: the project list, the categories actually in use and the published articles. Two decisions are worth copying.
First, lastModified is never the build time. A build timestamp changes on every deploy and tells a crawler nothing. The homepage, /work, /blog and /cv use the date of the newest article. Category pages use the newest article in that category. Each article uses its own updated date, falling back to its publication date.
Second, the sitemap lists only published articles. Articles carry a publication date and can be written ahead of it. A publishedPosts() function compares each date with today's date in Malaysia and filters out anything later. The blog routes, the feed and the sitemap all read that function and set export const revalidate = 3600, so an article appears in listings, the feed and the sitemap within the hour its date arrives, and not before.
The /cv page is in the sitemap because it is a public, indexable page. The CV document behind it is not, and the route that serves it sends its own X-Robots-Tag: noindex, nofollow header rather than relying on being left out of the sitemap.
An RSS feed from the same list
The RSS 2.0 feed at /feed.xml is a route handler, built from the same sorted post list. Each item uses the canonical article URL as both link and a permalink guid, and the article excerpt as its description, not the body. Publication dates are written from publishedAt(), which pins an article date to midnight in Malaysian time (+08:00) before converting to the RFC 822 form RSS expects. The reason for pinning the offset rather than trusting the server's clock is the same one described in The Business Day Is Not the Server Day.
Keeping old URLs alive with permanent redirects
On 24 September 2026 the blog's six categories were folded into six new clusters, and one article was merged into the article it duplicated. Every URL that existed before still answers, with a permanent redirect to where the content lives now. They are declared as data in next.config.ts and mapped into Next.js redirects:
const permanentRedirects = [
{ from: "/blog/category/operations", to: "/blog/category/building-real-systems" },
{ from: "/blog/category/ui-ux", to: "/blog/category/ui-ux-and-product" },
// five more
];
async redirects() {
return permanentRedirects.map(({ from, to }) => ({
source: from,
destination: to,
permanent: true,
}));
}permanent: true makes Next.js answer with a 308. The merged article redirects to the surviving one rather than to the blog index, because a redirect to a generic page is only a slower 404.
The checklist that came out of this: pick the host that answers 200 and build every absolute URL from it; derive sitemap dates from content; keep unpublished pages out of the sitemap and the feed; give every retired URL a permanent redirect to its closest replacement. The structured-data side of the same site is in One Person, Referenced Everywhere.
Keep reading
One Person, Referenced Everywhere: A JSON-LD @graph for a Personal Site
How this site declares its Person and WebSite nodes once and points every article, project page and the homepage at them by @id, and what that forced me to leave out.
Next.js Open Graph Metadata Replaces, It Doesn't Merge
A child route that sets its own openGraph object silently drops the layout's og:image and og:locale. Why Next.js does that, and the helper this site uses to stop it.
Structured Data for a Local Recycling Business: Organization, RecyclingCenter and an FAQPage That Matches the Page
Turning a client's schema requirements into JSON-LD for a Malaysian e-waste service: Organization plus RecyclingCenter, and an FAQPage that mirrors the visible FAQ.