Fixing SEO & Social Previews in SPAs Without SSR
Learn how to make client-side applications SEO friendly and give them proper link previews across platforms using Cloudflare Workers.
5 min read0 views
CRAs (client-rendered applications) and SPAs (single-page applications) are super popular for building modern web apps. But if you’ve worked with them, you’ve probably run into these annoying issues:
- Bad SEO: search engines struggle to index them properly.
- No social previews: links don’t generate those nice preview cards with images and descriptions.
These issues can be a dealbreaker, which is why many developers move to SSR frameworks like Next.js or Gatsby. But what if you actually want to stick with an SPA or CRA? Are you just out of luck?
Not really. Here’s the deal.
SEO isn’t as bad as it used to be
Search engines have gotten better at handling JavaScript-heavy apps. They can now download, parse and render them, so basic SEO isn’t as terrible as it was a few years ago. Is it as good as SSR? No. But it’s way better than nothing.
Social previews are still a pain
This is where things get messy. Even if you use something like react-helmet to insert Open Graph meta tags, social media crawlers don’t wait for JavaScript to load. They just scrape the raw HTML, which means your links won’t have a proper title, description or image. Instead of a nice preview card, you get… nothing.
So, is there a fix?
I spent way too much time looking for a solution. The only real way to fix this is to modify the HTML before it gets sent to the client. That means intercepting the request for index.html and injecting the meta tags dynamically.
Enter Cloudflare Workers
Think of Cloudflare Workers as global, serverless edge functions that sit between your users and your site. They run on Cloudflare’s network: think AWS Lambda, but faster, cheaper and far easier to deploy.
- Low latency
- Crazy fast deployments
- No expensive cloud bills
Read more in the Cloudflare Workers docs.
Injecting SEO metadata with a Worker
1. Create a Workers project
First, install the CLI and create a new project:
pnpm create cloudflare@latest my-first-worker2. Define metadata for each route
Inside index.ts, I created a simple Record<K, V> object:
const metaTags: Record<string, { title: string; description: string; image: string }> = {
'/': {
title: 'Prayag Dave - Indian Adventure and Nature Photographer',
description: 'Prayag Dave is a professional photographer based in India specializing in wildlife, travel, and commercial photography.',
image: 'https://your-image-url.com',
},
'/commercial': {
title: 'Commercial Photography Services',
description: 'Book professional commercial photography services with Prayag Dave.',
image: 'https://your-image-url.com/commercial.jpg',
},
};3. Intercept requests and inject meta tags
In the event listener, we:
- Extract the requested route.
- Check whether we have metadata for it.
- Fetch the original HTML and modify it before serving.
const url = new URL(request.url);
const meta = metaTags[url.pathname];
const response = await fetch(`${env.BASE_URL}/`);
let html = await response.text();
if (html.includes('<head>') && meta) {
html = html.replace(
'<head>',
`<head>
<meta name="description" content="${meta.description}">
<meta property="og:title" content="${meta.title}">
<meta property="og:description" content="${meta.description}">
<meta property="og:image" content="${meta.image}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
</head>`
);
}If for some reason the <head> tag is missing, we fall back to injecting it at the <html> level:
else {
html = html.replace('<html>', `<html><head></head>`);
}4. Deploy and set up routing
Once deployed, set up a worker route to intercept the specific routes that need metadata (e.g. /commercial/*):
my-website-url/commercial/*Now your social previews will work.
Why only specific routes? Because running the worker on /* (every route) means far more function invocations, which costs money. Being selective keeps things efficient.
Testing your social previews
To verify that it’s working, use this free tool: OpenGraph.xyz. Paste in your URL, and if everything’s set up right, you should see a preview card with the correct metadata.
Bonus: what if I’m not using Cloudflare?
Good news: most hosting providers now have their own edge functions that can do the same thing.
- Vercel: Vercel Edge Functions
- Netlify: Netlify Edge Functions
- AWS: Lambda@Edge
So if your site isn’t on Cloudflare, you can still apply the same idea with your provider’s edge computing features.
Wrapping up
This issue bothered me for a long time, so when I finally figured it out, I had to share it. Hopefully this helps if you’re struggling with SEO and social previews on SPAs.
Thanks for reading, and happy coding!