{
"$type": "site.standard.document",
"description": "Implementing open graph without re-writing your application to use SSR",
"path": "/blog/cf-worker-vue-seo",
"publishedAt": "2025-10-16T00:00:00.000Z",
"site": "at://did:plc:fuos6tklyozmefygjota4enw/site.standard.publication/self",
"tags": [
"Tech"
],
"textContent": "I Use Cloudflare pages to deploy a lot of my “static” websites. This one for example, uses this exact setup! (The blog has open graph tags to help other sites like Discord & Bluesky display the description and title)\nThe Problem\n\nMost open graph parsers don’t execute any javascript, they just load the HTML and parse it. For most normal applications, this is not a problem. Just insert the tags when you generate the HTML. For single page applications (Like VueJS by default) do NOT do that. They serve a generic index.html and then make the client insert the actual content via JavaScript\nThe solution\n\nWhen I was making https://pictoask.net i needed this as a solution, I needed a way for google to display profiles and posts properly. I Also really wanted people to be able to share their posts to social media, and have it display their doodles before anyone clicked a link.\n\nSo what were my criteria\n\n- I Preferably did not want to move away from Cloudflare Pages\n- I Did not want to have to run a backend service\n\nLuckily, after browsing the cloudflare documentation for a bit I found the following: \n\nOh yeah baby, that is exactly what I want.\n\nBasically, I ended up bundling a simple worker with my Vue SPA.\n\nThe worker code looks a little like this\n\njavascript\nimport Route from \"route-parser\";\n\nconst API = \"https://api.pictoask.net/api/v1/\";\nconst S3 = \"https://ams1.vultrobjects.com/pictoask/\";\n\nconst routes = [\n\n [new Route(\"/u/:username\"), async function(request, parts){\n let meta = await getUserMeta(parts.username);\n return await ApplyMeta(request, meta);\n }],\n [new Route(\"/p/:id\"), async function(request, parts){\n let meta = await getQuestionMeta(parts.id);\n return await ApplyMeta(request, meta);\n }],\n];\n\nasync function getUserMeta(username){\n\n const response = await fetch(API + \"users/\" + username);\n const data = await response.json();\n\n return {\n title: data.displayname + \" on PictoAsk\",\n 'og:url': https://pictoask.net/u/${username},\n 'og:description': data.bio,\n 'og:image': data.avatar ?? 'https://pictoask.net/users/img/users/nopicture.png',\n 'og:image:secureurl': data.avatar ?? 'https://pictoask.net/users/img/users/nopicture.png',\n 'og:image:width': 512,\n 'og:image:height': 512,\n 'og:type': 'profile',\n 'twitter:card': 'summary',\n }\n}\n\nasync function getQuestionMeta(id){\n\n const response = await fetch(API + \"questions/\" + id);\n const data = await response.json();\n\n return {\n title: data.isanonymous ? 'Anonymous question' : 'Question by ' + data.askedby.displayname,\n 'og:url': https://pictoask.net/p/${id},\n 'og:description': data.question,\n 'og:image': data.answerimage,\n 'og:image:secureurl': data.answerimage,\n 'og:image:width': 256,\n 'og:image:height': 512,\n 'og:type': 'article',\n 'twitter:card': 'summarylargeimage',\n }\n}\n\nasync function ApplyMeta(request, data = {}) {\n\n let html = await request.env.ASSETS.fetch(request);\n if(!data){\n return html;\n }\n\n class ElementHandler {\n async element(element) {\n\n if(element.tagName === 'title'){\n element.setInnerContent(data.title);\n }\n\n if(element.tagName === 'meta'){\n for(let [key, value] of Object.entries(data)){\n\n if(!key || !value){\n continue;\n }\n\n if(value.startsWith && value.startsWith(S3)){\n value = value.replace(S3, 'https://pictoask.net/media/');\n }\n\n if(element.getAttribute('name') === key){\n element.setAttribute('content', value);\n }\n\n if(element.getAttribute('property') === key){\n element.setAttribute('content', value);\n }\n\n // Twitter card stuff\n key = key.replace('og:', 'twitter:');\n if(element.getAttribute('property') === key){\n element.setAttribute('content', value);\n }\n }\n\n if(element.getAttribute('property') === 'og:title'){\n element.setAttribute('content', data.title);\n }\n }\n\n }\n }\n\n return new HTMLRewriter().on('title', new ElementHandler()).on('meta', new ElementHandler()).transform(html);\n\n}\n\nexport default {\n async fetch(request, env) {\n\n try {\n let path = new URL(request.url).pathname;\n request.env = env;\n console.log(path);\n\n for(let route of routes){\n let [r, fn] = route;\n let parts = r.match(path);\n if(parts){\n console.log(\"Matched route\", r, parts);\n return await fn(request, parts);\n }\n }\n }catch (e){\n console.error(e);\n }\n\n return env.ASSETS.fetch(request);\n\n }\n};\n\nCombine it with a simple routes.json to only send the relevant requests to the worker and bam, you’ve got meta tags! (Just make sure to add them to the index.html too, so they can be replaced)\n\nAnd this works… Flawlessly! Obviously if you have to do a cold-start for your worker the first request will now be a little slower, but for SEO and OpenGraph support I feel that is worth it!\n\nAlso, Using this same worker, you could generate your Sitemaps based on your API, but I chose to use a separate worker program for that.",
"title": "Getting nice embeds with a Vue3 SPA."
}