vue 3 Server Side Rendering with Vuex and Router
Asked Answered
A

2

12

I have created a Vue3 application using the Vue CLI to create my application with Vuex and Router. The application runs well.

Note: I followed this useful doc for the Vuex with Vue3 https://blog.logrocket.com/using-vuex-4-with-vue-3/

Requirement Now I would like to change my Vue3 application to have Server Side Rendering support(i.e. SSR).

I watched this awesome video on creating an SSR application using Vue3 : https://www.youtube.com/watch?v=XJfaAkvLXyU and I can create and run a simple application like in the video. However I am stuck when trying to apply it to my main Vue3 app.

My current sticking point is how to specify the router and vuex on the server code.

My Code

The client entry file (src/main.js) has the following

import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import store from './store';

createApp(App).use(store).use(router).mount('#app');

The server entry file (src/main.server.js) currently has the following

import App from './App.vue';
export default App;

And in the express server file (src/server.js) it currently has

const path = require('path');
const express = require('express');
const { createSSRApp } = require('vue');
const { renderToString } = require('@vue/server-renderer');

...
...

server.get('*', async (req, res) => {
  const app = createSSRApp(App);
  const appContent = await renderToString(app);

I need to change this code so that the app on the server side is using the router and vuex like it is on the client.

Issues

In the express server file i can not import the router and vuex like in the client entry file as it fails due to importing outside a module, therefore in the express server I can not do the following

const app = createSSRApp(App).use(store).use(router);

I have tried changing the server entry file (src/main.server.js) to the following, but this does not work either.

import App from './App.vue';
import router from './router';
import store from './store';

const { createSSRApp } = require('vue');

export default createSSRApp(App).use(store).use(router);

Does anyone know how to do SSR in Vue 3 when your app is using Vuex and Router.

How i did this in Vue 2 is below and what i am trying to change over to Vue 3

My Vue2 version of this application had the following code

src/app.js creates the Vue component with the router and store specified

Client entry file (src/client/main.js) gets the app from app.js, prepopulates the Vuex store with the data serialized out in the html, mounts the app when the router is ready

import Vue from 'vue';
import { sync } from 'vuex-router-sync';
import App from './pages/App.vue';
import createStore from './vuex/store';
import createRouter from './pages/router';

export default function createApp() {
  const store = createStore();
  const router = createRouter();
  sync(store, router);

  const app = new Vue({
  router,
  store,
  render: (h) => h(App),
  });

  return { app, router, store };
}

Server Entry file (src/server/main.js), gets the app from app.js, get the matched routes which will call the "serverPrefetch" on each component to get its data populated in the Vuex store, then returns the resolve promise

import createApp from '../app';

export default (context) => new Promise((resolve, reject) => {
  const { app, router, store } = createApp();

  router.push(context.url);

  router.onReady(() => {
  const matchedComponents = router.getMatchedComponents();
  if (!matchedComponents.length) {
    return reject(new Error('404'));
  }

  context.rendered = () => {
    context.state = store.state;
  };

  return resolve(app);
  }, reject);
});

Express server (/server.js) uses the bundle renderer to render the app to a string to put in the html

const fs = require('fs');
const express = require('express');
const { createBundleRenderer } = require('vue-server-renderer');
const dotenv = require('dotenv');

dotenv.config();

const bundleRenderer = createBundleRenderer(
  require('./dist/vue-ssr-server-bundle.json'),
  {
  template: fs.readFileSync('./index.html', 'utf-8'),
  },
);

const server = express();
server.use(express.static('public'));

server.get('*', (req, res) => {
  const context = {
  url: req.url,
  clientBundle: `client-bundle.js`,
  };

  bundleRenderer.renderToString(context, (err, html) => {
  if (err) {
    if (err.code === 404) {
    res.status(404).end('Page not found');
    } else {
    res.status(500).end('Internal Server Error');
    }
  } else {
    res.end(html);
  }
  });
});

const port = process.env.PORT || 3000
server.listen(port, () => {
  console.log(`Listening on port ${port}`);
});
Affecting answered 18/11, 2020 at 19:3 Comment(0)
A
23

I have managed to find the solution to this thanks to the following resources:

client entry file (src/main.js)

import buildApp from './app';

const { app, router, store } = buildApp();

const storeInitialState = window.INITIAL_DATA;
if (storeInitialState) {
  store.replaceState(storeInitialState);
}

router.isReady()
  .then(() => {
    app.mount('#app', true);
  });

server entry file (src/main-server.js)

import buildApp from './app';

export default (url) => new Promise((resolve, reject) => {
  const { router, app, store } = buildApp();

  // set server-side router's location
  router.push(url);

  router.isReady()
    .then(() => {
      const matchedComponents = router.currentRoute.value.matched;
      // no matched routes, reject with 404
      if (!matchedComponents.length) {
        return reject(new Error('404'));
      }

      // the Promise should resolve to the app instance so it can be rendered
      return resolve({ app, router, store });
    }).catch(() => reject);
});

src/app.js

import { createSSRApp, createApp } from 'vue';
import App from './App.vue';

import router from './router';
import store from './store';

const isSSR = typeof window === 'undefined';

export default function buildApp() {
  const app = (isSSR ? createSSRApp(App) : createApp(App));

  app.use(router);
  app.use(store);

  return { app, router, store };
}

server.js

const serialize = require('serialize-javascript');
const path = require('path');
const express = require('express');
const fs = require('fs');
const { renderToString } = require('@vue/server-renderer');
const manifest = require('./dist/server/ssr-manifest.json');

// Create the express app.
const server = express();

// we do not know the name of app.js as when its built it has a hash name
// the manifest file contains the mapping of "app.js" to the hash file which was created
// therefore get the value from the manifest file thats located in the "dist" directory
// and use it to get the Vue App
const appPath = path.join(__dirname, './dist', 'server', manifest['app.js']);
const createApp = require(appPath).default;

const clientDistPath = './dist/client';
server.use('/img', express.static(path.join(__dirname, clientDistPath, 'img')));
server.use('/js', express.static(path.join(__dirname, clientDistPath, 'js')));
server.use('/css', express.static(path.join(__dirname, clientDistPath, 'css')));
server.use('/favicon.ico', express.static(path.join(__dirname, clientDistPath, 'favicon.ico')));

// handle all routes in our application
server.get('*', async (req, res) => {
  const { app, store } = await createApp(req);

  let appContent = await renderToString(app);

  const renderState = `
    <script>
      window.INITIAL_DATA = ${serialize(store.state)}
    </script>`;

  fs.readFile(path.join(__dirname, clientDistPath, 'index.html'), (err, html) => {
    if (err) {
      throw err;
    }

    appContent = `<div id="app">${appContent}</div>`;

    html = html.toString().replace('<div id="app"></div>', `${renderState}${appContent}`);
    res.setHeader('Content-Type', 'text/html');
    res.send(html);
  });
});

const port = process.env.PORT || 8080;
server.listen(port, () => {
  console.log(`You can navigate to http://localhost:${port}`);
});

vue.config.js

used to specify the webpack build things

const ManifestPlugin = require('webpack-manifest-plugin');
const nodeExternals = require('webpack-node-externals');

module.exports = {
  devServer: {
    overlay: {
      warnings: false,
      errors: false,
    },
  },
  chainWebpack: (webpackConfig) => {
    webpackConfig.module.rule('vue').uses.delete('cache-loader');
    webpackConfig.module.rule('js').uses.delete('cache-loader');
    webpackConfig.module.rule('ts').uses.delete('cache-loader');
    webpackConfig.module.rule('tsx').uses.delete('cache-loader');

    if (!process.env.SSR) {
      // This is required for repl.it to play nicely with the Dev Server
      webpackConfig.devServer.disableHostCheck(true);

      webpackConfig.entry('app').clear().add('./src/main.js');
      return;
    }

    webpackConfig.entry('app').clear().add('./src/main-server.js');

    webpackConfig.target('node');
    webpackConfig.output.libraryTarget('commonjs2');

    webpackConfig.plugin('manifest').use(new ManifestPlugin({ fileName: 'ssr-manifest.json' }));

    webpackConfig.externals(nodeExternals({ allowlist: /\.(css|vue)$/ }));

    webpackConfig.optimization.splitChunks(false).minimize(false);

    webpackConfig.plugins.delete('hmr');
    webpackConfig.plugins.delete('preload');
    webpackConfig.plugins.delete('prefetch');
    webpackConfig.plugins.delete('progress');
    webpackConfig.plugins.delete('friendly-errors');

    // console.log(webpackConfig.toConfig())
  },
};

src/router/index.js

import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router';
import Home from '../views/Home.vue';
import About from '../views/About.vue';

const isServer = typeof window === 'undefined';
const history = isServer ? createMemoryHistory() : createWebHistory();
const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    component: About,
  },
];

const router = createRouter({
  history,
  routes,
});

export default router;

src/store/index.js

import Vuex from 'vuex';
import fetchAllBeers from '../data/data';

export default Vuex.createStore({
  state() {
    return {
      homePageData: [],
    };
  },

  actions: {
    fetchHomePageData({ commit }) {
      return fetchAllBeers()
        .then((data) => {
          commit('setHomePageData', data.beers);
        });
    },
  },

  mutations: {
    setHomePageData(state, data) {
      state.homePageData = data;
    },
  },

});

Github sample code

I found I needed to go through the building the code step by step doing just SSR, just Router, just Vuex and then put it all together.

My test apps are in github

https://github.com/se22as/vue-3-with-router-basic-sample

  • "master" branch : just a vue 3 app with a router
  • "added-ssr" branch : took the "master" branch and added ssr code
  • "add-just-vuex" branch : took the "master" branch and added vuex code
  • "added-vuex-to-ssr" branch : app with router, vuex and ssr.
Affecting answered 20/11, 2020 at 20:11 Comment(14)
For this to properly work, client side app needs to also be created with createSSRApp. Otherwise your existing markup won't be hydrated but just replaced with newly rendered markup.Fictive
The server is supposed to createSSRApp() from app.js not App.vue. It looks like in this the createSSRApp(App) is being run on App.vueAssyriology
Volume one = I am calling "createSSRApp()" in my app.js. My App.vue code simply specifies my layout with <router-view> tags.Affecting
@UliKrause Do you have further information on this. How i have it at the moment, when i run my application and view the source i see all the markup. However i do see a call to my data server which should not happen on the client as the data should have been obtained on the server.Affecting
I dont understand any SSR setup because none of them are building the client side. The SSR server entry file causes app.js to be built but not any of the node modules which are normally placed in vendor.js. If you do a separate client build then that creates another duplicate app.js file with a diff hash. So which one is server.js going to use and why make it twice?Assyriology
@volumeone To build the server code you would use the following vue-cli-service build --dest dist/server this places the built code in dist\server and to build the client i use vue-cli-service build --dest dist/client, this places the built code in dist\client. When you run the server app node server.js the server code in dist\server is used and it. The HTML returned to the client from the server will contain references to the client code in dist\client which is how the client gets the client bundle.Affecting
@Affecting Have you done ssr with typecript? If you can and it is not difficult for you, then could you add how to do it?Glasshouse
@Glasshouse no i used JavaScriptAffecting
@Glasshouse I use typescript in my project and have found that everything "just works" and haven't run into any issues regarding SSR and typesciptInept
@Affecting this is actually pretty helpful as the SSR documentation doesn't actually show that if it isn't Server Side that you need to call createApp otherwise only the developer build works. Anyway I am wondering how you can get the CSS as renderToString only returns the html and I don't want to put the CSS in a separate file for client side or server side rendering.Inept
This answer is quite old but given the number of upvotes I must comment. The code above is broken as it does not follow the ultimate rule of SSR apps - Avoid Stateful Singletons. store, router and history are stateful singletons so when multiple requests hit the server at the same time, all will share the store/router state leading to some very hard to debug errors...Superannuated
thanks for this useful answer, you are our heroWylma
Thanks for detailed write up. I am prerendering with Puppeteer rather than using SSR. Just to say the pointers around using router.onReady / isReady in the client main file are relevant there, too, otherwise <router-view> is empty and Vue sees a mismatch between its children and the pre-rendered version.Wot
But there is a more simple version (updated with the latest document in the Vue website): github.com/ThinhVu/vue--just-ssrGibb
D
5

You can also use Vite which has native SSR support and, unlike Webpack, works out-of-the-box without configuration.

And if you use vite-plugin-ssr then it's even easier.

The following highlights the main parts of vite-plugin-ssr's Vuex example

<template>
  <h1>To-do List</h1>
  <ul>
    <li v-for="item in todoList" :key="item.id">{{item.text}}</li>
  </ul>
</template>

<script>
export default {
  serverPrefetch() {
    return this.$store.dispatch('fetchTodoList');
  },
  computed: {
    todoList () {
      return this.$store.state.todoList
    }
  },
}
</script>
import Vuex from 'vuex'

export { createStore }

function createStore() {
  const store = Vuex.createStore({
    state() {
      return {
        todoList: []
      }
    },

    actions: {
      fetchTodoList({ commit }) {
        const todoList = [
          {
            id: 0,
            text: 'Buy milk'
          },
          {
            id: 1,
            text: 'Buy chocolate'
          }
        ]
        return commit('setTodoList', todoList)
      }
    },

    mutations: {
      setTodoList(state, todoList) {
        state.todoList = todoList
      }
    }
  })

  return store
}
import { createSSRApp, h } from 'vue'
import { createStore } from './store'

export { createApp }

function createApp({ Page }) {
  const app = createSSRApp({
    render: () => h(Page)
  })
  const store = createStore()
  app.use(store)
  return { app, store }
}
import { renderToString } from '@vue/server-renderer'
import { html } from 'vite-plugin-ssr'
import { createApp } from './app'

export { render }
export { addContextProps }
export { setPageProps }

async function render({ contextProps }) {
  const { appHtml } = contextProps
  return html`<!DOCTYPE html>
    <html>
      <body>
        <div id="app">${html.dangerouslySetHtml(appHtml)}</div>
      </body>
    </html>`
}

async function addContextProps({ Page }) {
  const { app, store } = createApp({ Page })

  const appHtml = await renderToString(app)

  const INITIAL_STATE = store.state

  return {
    INITIAL_STATE,
    appHtml
  }
}

function setPageProps({ contextProps }) {
  const { INITIAL_STATE } = contextProps
  return { INITIAL_STATE }
}
import { getPage } from 'vite-plugin-ssr/client'
import { createApp } from './app'

hydrate()

async function hydrate() {
  const { Page, pageProps } = await getPage()
  const { app, store } = createApp({ Page })
  store.replaceState(pageProps.INITIAL_STATE)
  app.mount('#app')
}
Diploma answered 28/2, 2021 at 16:39 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.