Coding in Public

Jun 9, 2025

Fix Your Canonical URLs with Next.js + Framer

In my last article, I showed you how to fix your SEO by moving from subdomains to subfolders using Next.js rewrites and Framer. But there's one critical piece I didn't cover and it's the kind of detail that can make or break your SEO setup.

The Problem Nobody Talks About

You've got your beautiful setup working: Framer handling your marketing site, Next.js handling your app, everything looking like one domain. Then you check your canonical URLs and... they're wrong.

Framer doesn't give you control over canonical URLs out of the box. So when Google crawls your site, it sees:

html

<link rel="canonical" href="https://www.respondra.com/pricing" />

But you want it to see:

html

<link rel="canonical" href="https://respondra.com/pricing" />

That www. might seem tiny, but to search engines, those are two different domains. You're diluting your SEO authority across multiple versions of the same URL.

When Support Doesn't Support

Here's where it gets frustrating. I reached out to Framer's support and sales team about this canonical URL issue. No response. Zero.

Look, I get it. They do offer a reverse proxy solution hosted on their side. But that's exactly the opposite of what I want as a developer. I want full control over reverse proxies on my end, not buried within the marketing website where I can't manage it properly.

This is exactly why developers hate reaching out for help. You spend time crafting a technical question, explaining your setup, and then... crickets. It's the kind of experience that makes you want to just build everything yourself.

But here's the thing. I still love Framer. It's genuinely great for what it does. My marketing team can move fast, the sites look beautiful, and the developer experience is solid. So instead of ditching it, I figured out the workaround.

Why This Matters More Than You Think

Search engines hate duplicate content. When you have both www.respondra.com/contact-us and respondra.com/contact-us pointing to the same content, you're essentially competing against yourself for rankings.

The canonical URL tells search engines: "This is the official version of this page." Get it wrong, and you're splitting your SEO juice between multiple URLs instead of concentrating it where it belongs.

The Two-Part Solution

Since Framer doesn't let us control canonical URLs directly, we need to fix this at two levels: server-side with Next.js middleware, and client-side with custom JavaScript.

Part 1: Next.js Middleware Fix

First, we catch and fix HTML responses on the server side. Here's the middleware that handles this:

import { NextRequest, NextResponse } from "next/server";

const allowedOrigins = ['https://respondra.com', 'https://respondra.dev'];

const corsOptions = {
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};

export async function middleware(request: NextRequest) {
  const response = NextResponse.next();
  
  // Add security headers
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
  
  // Existing CORS logic
  const origin = request.headers.get('origin') ?? '';
  const isAllowedOrigin = allowedOrigins.includes(origin);

  // Handle preflighted requests
  if (request.method === 'OPTIONS') {
    const preflightHeaders = {
      'Access-Control-Allow-Origin': isAllowedOrigin ? origin : 'No Origin Allowed',
      ...corsOptions,
    };
    return NextResponse.rewrite(new URL('/api/preflight', request.url), {
      headers: preflightHeaders,
      status: isAllowedOrigin ? 200 : 403,
      statusText: isAllowedOrigin ? 'OK' : 'Forbidden'
    });
  }

  // Handle simple requests
  if (isAllowedOrigin) {
    response.headers.set('Access-Control-Allow-Origin', origin);
  }

  // Handle HTML response rewriting for canonical URLs
  const contentType = response.headers.get('content-type');
  if (contentType?.includes('text/html')) {
    const html = await response.text();
    const modifiedHtml = html
      .replace(
        /<link\s+rel="canonical"\s+href="https:\/\/www\.respondra\.com([^"]*)"/g,
        '<link rel="canonical" href="https://respondra.com$1"'
      )
      .replace(
        /<meta\s+property="og:url"\s+content="https:\/\/www\.respondra\.com([^"]*)"/g,
        '<meta property="og:url" content="https://respondra.com$1"'
      );
    
    return new NextResponse(modifiedHtml, {
      status: response.status,
      statusText: response.statusText,
      headers: response.headers,
    });
  }

  return response;
}

The key part is the HTML rewriting section. We're intercepting HTML responses and using regex to replace any canonical URLs and Open Graph URLs that have www. with the clean version.

Part 2: Client-Side Framer Script

But middleware alone isn't enough. Framer might inject canonical URLs after the initial HTML loads, so we need client-side insurance. Add this script to your Framer site's Custom Code section:

<script>
  window.addEventListener('load', () => {
    const setCanonicalUrl = (url) => {
      // Update canonical URL
      const existingCanonical = document.querySelector('link[rel="canonical"]');
      if (existingCanonical) existingCanonical.remove();

      const canonicalLink = document.createElement('link');
      canonicalLink.rel = 'canonical';
      canonicalLink.href = url;
      document.head.appendChild(canonicalLink);

      // Update og:url
      const existingOgUrl = document.querySelector('meta[property="og:url"]');
      if (existingOgUrl) existingOgUrl.remove();

      const ogUrlMeta = document.createElement('meta');
      ogUrlMeta.setAttribute('property', 'og:url');
      ogUrlMeta.setAttribute('content', url);
      document.head.appendChild(ogUrlMeta);
    };

    // Remove 'www.' from current URL
    const currentUrl = window.location.href;
    let canonicalUrl = currentUrl.replace(/^https?:\/\/www\./, 'https://');

    // Fallback if not on respondra.com
    if (!canonicalUrl.includes('respondra.com')) {
      canonicalUrl = currentUrl;
    }

    setCanonicalUrl(canonicalUrl);
  });
</script>

This script waits for the page to fully load, then removes any existing canonical and og:url tags and replaces them with the correct versions without www..

Why Both Solutions?

You might wonder why we need both middleware and client-side fixes. Here's the deal:

Middleware: Catches most cases and ensures search engine crawlers see the right canonical URLs immediately Client-side script: Insurance policy for any dynamic content or edge cases where Framer injects tags after page load

Together, they create a bulletproof system that ensures your canonical URLs are always correct.

Testing Your Setup

Want to verify this is working? Here's how:

  1. Visit your marketing pages

  2. View page source (Ctrl+U or Cmd+U)

  3. Search for "canonical"

  4. Confirm you see href="https://yourdomain.com/path" without www

You should also check that your Open Graph URLs are consistent for social media sharing.

The Real 5% Edge

This canonical URL fix might seem like overkill, but it's exactly the kind of detail that separates good implementations from great ones. Your marketing team gets their independence, your SEO stays strong, and you don't lose ranking authority to duplicate URLs.

Most developers who do the subdomain-to-subfolder migration miss this step entirely. Then six months later, they wonder why their SEO isn't improving as much as expected.

That's your 5% edge right there - handling the details that matter but are easy to overlook.

What's Next?

In the next article, I'll show you how to handle the sitemap challenge when you're running this hybrid setup. Because yes, there's another integration detail that most people miss.

Have you run into canonical URL issues with your setup? Let me know in the comments. These edge cases are where we learn the most about building robust integrations.


Blog