Understanding Server-Side Rendering (SSR)

Overview

What is SSR?

Vue.js, a progressive JavaScript framework, is inherently designed for building client-side applications. This means Vue components typically generate and manipulate the Document Object Model (DOM) directly within a web browser. However, Vue.js also offers a powerful capability called Server-Side Rendering (Ssr). SSR allows you to render these same Vue components into HTML strings on a server. This pre-rendered HTML is then sent to the browser, significantly speeding up the initial page load. Once the HTML reaches the client, Vue “hydrates” this static markup, transforming it into a fully interactive and dynamic application.

A Vue.js application utilizing SSR is often described as “isomorphic” or “universal”. This terminology highlights the key characteristic of SSR: a substantial portion of your application’s code is executed on both the server and the client, fostering code reusability and consistency.

Why SSR?

Choosing Server-Side Rendering over a traditional client-side Single-Page Application (SPA) offers several compelling advantages, primarily centered around enhanced performance and SEO:

  • Faster Time-to-Content: This is arguably the most significant benefit of SSR, particularly noticeable on slower internet connections or less powerful devices. With SSR, the browser receives fully rendered HTML almost instantly. Users don’t have to wait for large JavaScript bundles to download, parse, and execute before seeing content. Furthermore, initial data fetching is handled on the server, which typically enjoys a faster and more stable connection to backend databases than a client-side application. This optimized content delivery translates directly to improved Core Web Vitals – key metrics for user experience and search engine ranking. Faster time-to-content dramatically enhances user experience, reduces bounce rates, and can be crucial for applications where initial load time directly impacts conversion rates, such as e-commerce sites.

  • Unified Mental Model: SSR allows developers to use the same programming language (JavaScript) and the same declarative, component-based architecture for both the front-end and back-end of their application. This eliminates the need to switch between different templating systems or backend languages and frontend frameworks. This unified approach simplifies development, reduces cognitive load, and improves team collaboration.

  • Improved Search Engine Optimization (SEO): Search engine crawlers, while increasingly sophisticated, traditionally index content more effectively when it’s readily available in the initial HTML. SSR delivers precisely that – a fully rendered page that search engine crawlers can easily parse and index. This is especially critical for content-heavy websites or applications where organic search traffic is a primary acquisition channel.

    Tip: Modern search engines like Google and Bing are now capable of indexing synchronous JavaScript applications effectively. However, the keyword here is synchronous. If your SPA relies on asynchronous data fetching after the initial page load (e.g., displaying a loading spinner first), crawlers may not wait for the content to load. In such scenarios, especially for SEO-sensitive pages, SSR becomes essential to ensure search engines see the complete, rendered content.

However, SSR is not a silver bullet and comes with its own set of trade-offs:

  • Development Constraints: SSR introduces certain restrictions during development. Browser-specific code must be carefully isolated and executed only within specific client-side lifecycle hooks. Some third-party libraries not designed for universal environments might require special handling or workarounds to function correctly in an SSR application.

  • Increased Build Complexity and Deployment Requirements: Unlike fully static SPAs that can be hosted on simple static file servers, SSR applications require a Node.js server environment to handle server-side rendering. This adds complexity to the build process and necessitates a more sophisticated deployment infrastructure.

  • Higher Server Load: Rendering the entire application on the server for each request consumes more CPU resources compared to simply serving static files. If your application anticipates high traffic, you must adequately provision your server infrastructure and implement robust caching strategies to mitigate server load and maintain performance.

Before adopting SSR, it’s crucial to evaluate whether its benefits genuinely outweigh the added complexity for your specific project. The primary deciding factor is the importance of time-to-content for your application. For internal dashboards or applications where a slight delay in initial load is acceptable, SSR might be an unnecessary overhead. However, for user-facing applications where initial load performance is paramount, SSR can be a powerful tool to deliver the best possible user experience.

SSR vs. SSG

Static Site Generation (SSG), also known as pre-rendering, is another popular technique for building high-performance websites. SSG is particularly well-suited for websites where the data required for rendering a page is consistent across all users and doesn’t change frequently. Instead of rendering pages dynamically with each request (as in SSR), SSG renders pages once during the build process. These pre-rendered pages are then served as static HTML files, offering exceptional speed and efficiency.

SSG shares the performance advantages of SSR in terms of time-to-content. Furthermore, SSG deployments are generally simpler and more cost-effective than SSR deployments because they only require serving static HTML and assets. The key distinction is the “static” nature of SSG. SSG is best suited for content that is known at build time and doesn’t need to change between user requests. Any data updates necessitate a rebuild and redeployment of the site.

If your primary goal for considering SSR is to improve SEO for a limited number of marketing pages (e.g., homepage, about us, contact pages), SSG is often a more efficient and less complex solution. SSG is also an excellent choice for content-centric websites like documentation sites, blogs, or portfolios. In fact, this very website you are currently reading is statically generated using VitePress, a static site generator powered by Vue.

Basic Tutorial

Rendering an App

Let’s walk through a fundamental example to illustrate Vue SSR in action.

  1. Begin by creating a new project directory and navigating into it using your terminal:

    mkdir vue-ssr-example
    cd vue-ssr-example
  2. Initialize a package.json file with default settings:

    npm init -y
  3. Modify your package.json file to include "type": "module". This configuration ensures that Node.js operates in ES modules mode, enabling modern JavaScript module syntax.

    {
      "name": "vue-ssr-example",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "type": "module",
      "scripts": {
        "test": "echo "Error: no test specified" && exit 1"
      },
      "keywords": [],
      "author": "",
      "license": "ISC"
    }
  4. Install Vue.js as a project dependency:

    npm install vue
  5. Create a file named example.js. This file will contain our basic SSR code.

    // example.js
    // This code runs in Node.js on the server.
    import { createSSRApp } from 'vue'
    // Vue's server-rendering API is available under 'vue/server-renderer'.
    import { renderToString } from 'vue/server-renderer'
    
    const app = createSSRApp({
      data: () => ({ count: 1 }),
      template: `<button @click="count++">{{ count }}</button>`
    })
    
    renderToString(app).then((html) => {
      console.log(html)
    })

    In this code:

    • We import createSSRApp from ‘vue’ to create a Vue application instance suitable for SSR.
    • We import renderToString from ‘vue/server-renderer’, which is the core function for rendering a Vue app to an HTML string on the server.
    • We define a simple Vue application with a reactive count data property and a template that displays a button.
    • renderToString(app) takes our Vue app instance and returns a Promise that resolves with the rendered HTML string.
    • We use .then() to handle the Promise and log the resulting HTML to the console.
  6. Execute the example.js file using Node.js:

    node example.js

    Running this command should output the following HTML string to your command line:

    <button>1</button>

    The renderToString() function is the key to server-side rendering. It takes a Vue application instance and returns a Promise that resolves to the complete HTML markup of the application. Vue SSR also supports streaming rendering using Node.js Streams API or Web Streams API for more advanced scenarios. Refer to the SSR API Reference for comprehensive details on rendering options.

    To create a functional server, we can integrate this Vue SSR code into a server request handler. We’ll use the popular express framework for this purpose in the following steps. express will help us set up a basic web server to serve our SSR application.

  7. Install express and save it to your project dependencies:

    npm install express
  8. Create a new file named server.js. This file will house our Express server setup and integrate the Vue SSR logic.

    // server.js
    import express from 'express'
    import { createSSRApp } from 'vue'
    import { renderToString } from 'vue/server-renderer'
    
    const server = express()
    
    server.get('/', (req, res) => {
      const app = createSSRApp({
        data: () => ({ count: 1 }),
        template: `<button @click="count++">{{ count }}</button>`
      })
    
      renderToString(app).then((html) => {
        res.send(`
          <!DOCTYPE html>
          <html>
          <head>
            <title>Vue SSR Example</title>
          </head>
          <body>
            <div id="app">${html}</div>
          </body>
          </html>
        `)
      })
    })
    
    server.listen(3000, () => {
      console.log('Server ready at http://localhost:3000')
    })

    In this server.js file:

    • We import express, createSSRApp, and renderToString.
    • We initialize an Express server instance.
    • We define a route for the root path (/) using server.get('/').
    • Within the route handler, we create a Vue SSR app instance, just as we did in example.js.
    • We use renderToString(app) to render the app to HTML.
    • We then send a complete HTML page to the client using res.send(). This HTML page includes:
      • The basic HTML structure (<!DOCTYPE html>, <html>, <head>, <body>).
      • A <title> element for the page title.
      • A <div id="app"> element where our Vue application’s HTML will be injected.
      • The HTML rendered by renderToString(app) is inserted into the <div id="app">.
    • Finally, server.listen(3000, ...) starts the Express server and makes it listen for requests on port 3000.
  9. Launch the server by running the following command:

    node server.js

    You should see the message “Server ready at http://localhost:3000” in your console.

  10. Open your web browser and navigate to http://localhost:3000. You should see a webpage displaying a button with the number “1”.

    Try it on StackBlitz

Client Hydration

If you interact with the button on the webpage (click it), you’ll notice that the number does not change. This is because the HTML sent from the server is static. We haven’t yet loaded Vue.js in the browser to make the page interactive.

To make the client-side application dynamic and interactive, Vue needs to perform a process called hydration. During hydration, Vue takes the pre-rendered HTML from the server and “hydrates” it into a live, interactive Vue application. It does this by:

  • Creating the same Vue application instance in the browser as was used on the server.
  • Traverses the DOM tree of the pre-rendered HTML.
  • Matches Vue components to the corresponding DOM nodes they should manage.
  • Attaches event listeners to the DOM elements, enabling interactivity.

To enable client-side hydration, we need to modify our client-side code to use createSSRApp() instead of the standard createApp(). createSSRApp() is specifically designed for creating Vue applications that will be hydrated from server-rendered HTML.

  1. Create a new file named client.js:

     // client.js
     // This code runs in the browser.
     import { createSSRApp } from 'vue'
    
     const app = createSSRApp({
       data: () => ({ count: 1 }),
       template: `<button @click="count++">{{ count }}</button>`
     })
    
     // Mounting an SSR app on the client assumes the HTML was pre-rendered
     // and will perform hydration instead of mounting new DOM nodes.
     app.mount('#app')

    In client.js:

    • We again use createSSRApp from ‘vue’.
    • We define the same Vue application logic (data and template) as we used on the server. It’s crucial that the client-side app matches the server-side app structure.
    • We call app.mount('#app') to mount the Vue application to the <div id="app"> element in our HTML. Because we are using createSSRApp for mounting on the client, Vue will automatically perform hydration.
  2. Modify server.js to serve static files and include client.js in the HTML.

     // server.js
     import express from 'express'
     import { createSSRApp } from 'vue'
     import { renderToString } from 'vue/server-renderer'
    
     const server = express()
    
     // Serve static files (client.js) from the current directory
     server.use(express.static('.'))
    
     server.get('/', (req, res) => {
       const app = createSSRApp({
         data: () => ({ count: 1 }),
         template: `<button @click="count++">{{ count }}</button>`
       })
    
       renderToString(app).then((html) => {
         res.send(`
           <!DOCTYPE html>
           <html>
           <head>
             <title>Vue SSR Example</title>
           </head>
           <body>
             <div id="app">${html}</div>
             <script type="module" src="/client.js"></script>
           </body>
           </html>
         `)
       })
     })
    
     server.listen(3000, () => {
       console.log('Server ready at http://localhost:3000')
     })

    Key changes in server.js:

    • server.use(express.static('.')) is added. This line tells Express to serve static files from the current directory. This is necessary so that the browser can access client.js.
    • <script type="module" src="/client.js"></script> is added to the HTML template. This line includes client.js in the HTML page that is sent to the browser. type="module" is essential because we are using ES modules.
  3. Refresh http://localhost:3000 in your browser. Now, when you click the button, the number should increment interactively! The page is now fully hydrated and interactive.

    Try the completed example on StackBlitz.

Code Structure

Notice that in the previous example, we had to duplicate the Vue application’s logic (the component definition with data and template) in both server.js and client.js. In a real-world SSR application, you’ll want to share the same application code between the server and the client to maintain consistency and avoid code duplication.

This leads us to the concept of universal code – code that can run in both Node.js on the server and in the browser on the client. Structuring your application to maximize universal code is crucial for effective SSR.

Let’s refactor our example to separate the app creation logic into a dedicated app.js file that can be shared.

  1. Create a new file named app.js:

     // app.js (shared between server and client)
     import { createSSRApp } from 'vue'
    
     // Export a function that creates the app instance
     // This function will be called by both the server and the client
     export function createApp() {
       return createSSRApp({
         data: () => ({ count: 1 }),
         template: `<button @click="count++">{{ count }}</button>`
       })
     }

    In app.js:

    • We export a function called createApp.
    • This function encapsulates the creation of our Vue SSR application instance.
    • By exporting a function instead of the app instance directly, we ensure that a new app instance is created each time createApp() is called. This is important for SSR to avoid state pollution across requests (explained later).
  2. Modify client.js to import and use createApp from app.js:

     // client.js
     import { createApp } from './app.js' // Import the createApp function
    
     const { app } = createApp() // Call createApp to get a new app instance
    
     app.mount('#app')

    In client.js:

    • We import the createApp function from ./app.js.
    • We call createApp() to get a new Vue application instance.
    • We then mount this app instance as before.
  3. Modify server.js to also import and use createApp from app.js:

     // server.js
     import express from 'express'
     import { createApp } from './app.js' // Import the createApp function
     import { renderToString } from 'vue/server-renderer'
    
     const server = express()
     server.use(express.static('.'))
    
     server.get('/', (req, res) => {
       const { app } = createApp() // Call createApp to get a new app instance
    
       renderToString(app).then((html) => {
         res.send(`
           <!DOCTYPE html>
           <html>
           <head>
             <title>Vue SSR Example</title>
           </head>
           <body>
             <div id="app">${html}</div>
             <script type="module" src="/client.js"></script>
           </body>
           </html>
         `)
       })
     })
    
     server.listen(3000, () => {
       console.log('Server ready at http://localhost:3000')
     })

    In server.js:

    • We also import the createApp function from ./app.js.
    • We call createApp() to get a new Vue application instance for each request.

With this refactoring, the core application logic is now centralized in app.js, making it universal code shared between the server and the client. This is a fundamental step towards building more complex and maintainable SSR applications.

In addition to serving static files and including client.js, for more advanced applications, you might also need to:

  • Handle different build processes for client and server code.
  • Manage client-side assets (CSS, images, etc.) in the server-rendered HTML.
  • Implement routing and state management in a universal way.

Higher Level Solutions

Building a production-ready SSR application from scratch, as demonstrated in the basic tutorial, involves significant complexity. You need to handle:

  • Build Processes: Separate but coordinated build processes for both the client and server bundles of your application. Vue components are compiled differently for SSR to optimize rendering performance on the server.
  • Asset Management: Correctly injecting links to client-side assets (JavaScript, CSS, images) into the server-rendered HTML and providing optimal resource hints for browser loading.
  • Mode Switching: Potentially switching between SSR and SSG modes within the same application or even mixing both approaches for different parts of your site.
  • Universal Routing and State Management: Implementing routing, data fetching, and state management solutions that work seamlessly on both the server and the client.

Manually managing these complexities can be challenging and error-prone, especially as your application grows. Therefore, it’s highly recommended to leverage higher-level, opinionated frameworks and solutions that abstract away much of this complexity. Here are some excellent SSR solutions within the Vue ecosystem:

Nuxt

Nuxt is a comprehensive, higher-level framework built on top of Vue.js. It provides a streamlined and highly productive development experience specifically tailored for building universal Vue applications. Nuxt simplifies SSR setup and configuration significantly. Furthermore, Nuxt also supports Static Site Generation (SSG) out of the box, allowing you to choose the optimal rendering strategy for different parts of your application. Nuxt is the most popular and widely recommended framework for building SSR Vue applications. If you’re serious about SSR with Vue, exploring Nuxt is highly recommended.

Quasar

Quasar is another powerful, Vue-based framework that offers a truly “write-once, deploy-everywhere” approach. Quasar allows you to target a wide range of platforms from a single codebase, including SPAs, SSR applications, PWAs (Progressive Web Apps), mobile apps (using Cordova or Capacitor), desktop apps (using Electron), and even browser extensions. Quasar not only handles the complexities of SSR build setup but also provides a rich collection of high-quality, Material Design compliant UI components, making it a complete solution for building sophisticated Vue applications across various platforms.

Vite SSR

Vite, the next-generation frontend tooling, provides built-in support for Vue Server-Side Rendering. However, Vite’s SSR support is intentionally low-level, offering maximum flexibility but requiring more manual configuration. If you prefer to work directly with Vite and want a more hands-on approach to SSR setup, vite-plugin-ssr is a highly recommended community plugin. vite-plugin-ssr builds upon Vite’s core SSR capabilities and abstracts away many of the more intricate details, making Vite-based SSR development more accessible and manageable.

For developers who want to deeply understand the inner workings of Vite SSR and have complete control over the higher-level architecture, the official Vite repository also provides an example Vue + Vite SSR project with manual setup here. This example can serve as a solid foundation for building upon, but it’s generally recommended for developers with prior SSR experience and a strong understanding of build tools.

Writing SSR-friendly Code

Regardless of whether you choose a higher-level framework like Nuxt or opt for a more manual setup with Vite, certain fundamental principles apply to writing code that works correctly and efficiently in a Vue SSR application. These principles revolve around understanding the differences between the server and client environments and writing “universal” code that accounts for these differences.

Reactivity on the Server

During the server-side rendering phase, the primary goal is to generate the initial HTML for a specific URL request. There is no user interaction, no DOM updates, and no need for dynamic reactivity in the same way as in a client-side application. To optimize performance during SSR, Vue.js disables reactivity by default on the server. This means that reactive data changes within components during SSR will not trigger DOM updates or re-renders on the server. Reactivity is only activated when the application is hydrated on the client-side.

Component Lifecycle Hooks

Component lifecycle hooks behave differently in SSR compared to client-side rendering. Lifecycle hooks that are related to DOM manipulation or component mounting and updating (such as mounted, onMounted, updated, onUpdated) are not executed during server-side rendering. These hooks are designed to run in the browser environment and are only invoked during client-side hydration and subsequent updates.

The only lifecycle hooks that are called during SSR are beforeCreate and created (and their Composition API equivalents, setup() and the root scope of <script setup>). These hooks are executed both on the server and the client.

It’s crucial to avoid performing actions with side effects that require cleanup (like setting timers with setInterval) within beforeCreate, created, or setup(). In client-side code, you might set up a timer in mounted or onMounted and then clear it in beforeUnmount or onBeforeUnmount. However, because unmount hooks are not called during SSR, timers or other side effects initiated in beforeCreate, created, or setup() on the server will persist indefinitely, potentially leading to resource leaks or unexpected behavior. To prevent this, move side-effect logic and code that requires cleanup to client-side-only lifecycle hooks like mounted or onMounted.

Access to Platform-Specific APIs

Universal code, by its nature, must be able to run in both Node.js (server) and browser environments. Therefore, universal code cannot directly assume access to platform-specific APIs. If your code directly uses browser-only globals like window, document, or localStorage, it will throw errors when executed in Node.js during SSR. Conversely, Node.js-specific APIs will not be available in the browser.

For tasks that need to be performed in both server and client environments but require different platform-specific APIs, the recommended approach is to abstract the platform differences using a universal API or to use libraries that provide platform-agnostic implementations. For example, for making HTTP requests, you can use the node-fetch library. node-fetch provides the same Fetch API in both Node.js and browser environments, allowing you to write universal code for data fetching.

For APIs that are genuinely browser-specific and have no server-side equivalent (e.g., window, document, localStorage), the common practice is to access them lazily within client-side-only lifecycle hooks such as mounted or onMounted. This ensures that code accessing browser-specific APIs is only executed in the browser environment after hydration.

Integrating third-party libraries into SSR applications can sometimes be challenging if those libraries are not designed with universal usage in mind. Some libraries might make assumptions about the environment (e.g., assuming they are always running in a browser) or directly access browser globals. In some cases, you might be able to work around these issues by mocking certain globals or using conditional loading, but these approaches can be complex and might introduce compatibility problems or interfere with the environment detection logic of other libraries. When choosing third-party libraries for SSR projects, prioritize libraries that are explicitly designed for universal or isomorphic usage.

Cross-Request State Pollution

In client-side SPAs, JavaScript modules are typically initialized anew for each browser page visit. This means that if you declare shared state (e.g., using Vue’s reactivity APIs or a simple state management pattern) in a module’s root scope, each page visit gets its own fresh instance of that state.

However, in an SSR context, application modules are generally initialized once when the Node.js server starts. These same module instances are then reused across multiple incoming server requests. This can lead to a critical issue called cross-request state pollution. If you mutate shared singleton state objects with data specific to one user’s request, this data can inadvertently “leak” and become visible in subsequent requests from other users.

To prevent cross-request state pollution, the best practice is to create a new instance of your entire application – including Vue app instance, router, state management stores, and any other global services – for each incoming server request.

Instead of directly importing shared state or store instances into your components, you should use Vue’s [app-level provide](

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *