Many developers eventually run into the same situation. You are working with an existing system and you know there is a much better way to solve the problem, but replacing the current technology simply is not realistic. Rewriting everything would cost far more than the benefits it would bring.
I encountered exactly this scenario while working at an agency.
Our client operated a classical CMS that generated full HTML pages. Inside those pages Vue components were embedded and rendered on the client. We built the Vue components while the client used the CMS to manage the website and page content.
As the website grew, the downsides of this architecture became more visible. Rendering happened almost entirely in the browser, which led to layout shifts and a large amount of additional HTTP requests triggered by components fetching their own data. The problem was especially noticeable on mobile devices where the client side workload became significant.
Server Side Rendering would solve many of these issues. The components would already be rendered inside the HTML that the browser receives, eliminating layout shifts and reducing the amount of client side work. Resources could be loaded earlier and metadata such as preload tags could be generated during rendering.
Unfortunately the existing architecture made this difficult. Vue SSR normally assumes that a Node.js server renders a known application template. In our case the CMS produced fully dynamic HTML pages, so there was no static template that could be compiled ahead of time.
Instead of proposing a full rewrite, I started exploring whether SSR could be added after the CMS had already generated its HTML.
The idea
The key insight was that the CMS itself did not necessarily need to change. Instead we could add a transformation step between the CMS and the browser.
A reverse proxy can sit in front of the CMS and intercept responses. The proxy forwards requests to the CMS, receives the generated HTML, runs a Vue SSR transformation on the page, and finally returns the transformed document to the browser.
From the perspective of the CMS nothing has changed. It still produces the same HTML as before. The proxy simply post processes the output before it reaches the user.
A proof of concept implementation of this approach is available here:
github.com/gweiermann/vue-ssr-postprocessing
How the transformation works
To make this work we need to replicate the two responsibilities normally handled by SSR frameworks such as Nuxt.
First, we must render the Vue components on the server. Second, the client must still be able to hydrate the application so that it becomes interactive after loading.
Vue already provides the core functionality we need. The server renderer exposes a function called renderToString which takes a Vue application and returns the rendered HTML.
The more unusual part of this setup is that we do not have a predefined template. Instead we extract the template directly from the HTML generated by the CMS. That template is used on the server to render the components and later sent back to the client so Vue can hydrate the application correctly.
In practice this means the response contains both the rendered HTML and the original template string. The additional size overhead turned out to be surprisingly small.
Implementation
The heart of the transformation is a function that receives the HTML generated by the CMS and returns a transformed version.
// ssr/ssr.js
import createVueApp from '@/app.js'
import * as cheerio from 'cheerio'
import { renderToString } from 'vue/server-renderer'
export default async function ssr(html) {
const $ = cheerio.load(html)
const appContainer = $('#app')
const template = appContainer.html()
const app = createVueApp(template)
const vueRender = await renderToString(app)
appContainer.html(vueRender)
const injectedTemplate = JSON.stringify(template)
$('head').append(`
<script>
window.SSR_TEMPLATE = ${injectedTemplate}
</script>
`)
return $.html()
}
The function receives the HTML produced by the CMS and parses it using Cheerio. This allows us to manipulate the document before sending it back to the browser.
const $ = cheerio.load(html)
const appContainer = $('#app')
For now we only need to focus on the Vue application container. Everything else generated by the CMS remains untouched.
Next we extract the original template and create the Vue SSR application.
const template = appContainer.html()
const app = createVueApp(template)
The createVueApp function mirrors the client side setup.
// src/app.js
import HelloWorld from "@/components/HelloWorld.vue";
import TheWelcome from "@/components/TheWelcome.vue";
import { createSSRApp } from "vue$";
export default function createVueApp(template) {
return createSSRApp({
template,
components: {
HelloWorld,
TheWelcome
}
})
}
Exemplary the components HelloWorld and TheWelcome are imported and registered globally. The CMS can embed these components directly into its generated HTML. The extracted template becomes the root template for the Vue application.
Rendering the application is then straightforward.
const vueRender = await renderToString(app)
The resulting HTML replaces the original template inside the container.
appContainer.html(vueRender)
At this point the browser would receive a fully rendered document. However Vue still needs access to the original template in order to hydrate the application correctly. To solve this we inject the template into the page.
const injectedTemplate = JSON.stringify(template)
$('head').append(`
<script>
window.SSR_TEMPLATE = ${injectedTemplate}
</script>
`)
Using JSON.stringify ensures the template string is safely escaped before embedding it into JavaScript.
On the client side the application can now hydrate using that template.
// src/main.js
import createVueApp from "@/app.js";
createVueApp(window.SSR_TEMPLATE).mount('#app')
The same createVueApp function is reused on both server and client.
A minimal Express server that implements the proxy layer can be found here:
https://github.com/gweiermann/vue-ssr-postprocessing/blob/main/ssr/server.js
Passing context during SSR
The project I worked on had a small mechanism to dynamically load CSS. The CMS inserted elements like this into the page:
<css-import href="/css/style.css" />
At runtime this element produced a hidden placeholder and injected a <link> element into the document head.
With SSR we can improve this behavior and generate the stylesheet link directly during rendering.
Vue allows passing a context object to renderToString.
const context = {
cssSources: []
}
const vueRender = await renderToString(app, context)
Components can then enrich that context during server rendering.
import { useSSRContext } from 'vue'
if (import.meta.env.SSR) {
const ctx = useSSRContext()
ctx.cssSources.push('/css/style.css')
}
After rendering finishes, the proxy can read the collected values from context.cssSources and inject the appropriate <link> tags into the document head.
Things to watch out for
The original template extracted from the CMS must be valid HTML5. Self closing custom elements such as
<my-component />
can break the Vue renderer. They should instead use explicit opening and closing tags.
Another detail is that Vue code running during SSR must avoid accessing browser specific APIs such as window or document.
Summary
This approach makes it possible to introduce Server Side Rendering into an existing CMS architecture without modifying the CMS itself. A reverse proxy intercepts the generated HTML, renders the Vue components on the server, and returns the transformed page to the browser.
The result is a fully rendered page that still hydrates correctly on the client. Performance improves because layout shifts disappear and the browser has less work to do.
In my case the approach never reached production because the agency’s client did not want to introduce the additional infrastructure layer. Still, the experiment showed that it is possible to bridge the gap between traditional CMS systems and modern SSR techniques with surprisingly little code.
GitHub repository:
github.com/gweiermann/vue-ssr-postprocessing
Further ideas
Several areas could be explored further. Cheerio could potentially be replaced with a lighter HTML parser. It might also be possible to remove the client side Vue runtime bundle, although that would likely require a more complex build step. Another natural extension would be adding caching at the proxy layer to avoid repeated SSR work for identical pages.