loading...
Cover image for Quasar - SSR and using cookies
Quasar

Quasar - SSR and using cookies

tobymosque profile image Tobias Mesquita Updated on ・7 min read

Getting Quasar’s SSR cookie plugin working with other libraries and services.

Table Of Contents

1 - Introduction

If you've read the Quasar docs regarding the Cookies plugin, you probably also noticed a small note about how to use this plugin in a SSR app.

When building for SSR, use only the $q.cookies form. If you need to use the import { Cookies } from 'quasar', then you’ll need to do it like this:

import { Cookies } from 'quasar'

// you need access to `ssrContext`
function (ssrContext) {
  const cookies = process.env.SERVER
    ? Cookies.parseSSR(ssrContext)
    : Cookies // otherwise we're on client

  // "cookies" is equivalent to the global import as in non-SSR builds
}

The ssrContext is available in App Plugins or preFetch feature where it is supplied as parameter.

The reason for this is that in a client-only app, every user will be using a fresh instance of the app in their browser. For server-side rendering we want the same: each request should have a fresh, isolated app instance so that there is no cross-request state pollution. So Cookies needs to be bound to each request separately.

Now let's imagine you're using axios with interceptors to consume your REST API, and you're configuring everything in a boot file like something similar to this:

./src/boot/axios.js

import Vue from 'vue'
import axios from 'axios'

const axiosInstance = axios.create({
  baseURL: 'https://api.example.com'
})

axiosInstance.interceptors.request.use(config => {
  let token = localStorage.getItem("token")
  if (token) {
    config.headers.Authorization = `bearer ${token}`
  }
  return config;
}, error => {
  return Promise.reject(error)
})

Vue.prototype.$axios = axiosInstance

export { axiosInstance }

You're using this axios instance in order to consume a REST API which is behind an authorization wall and you're storing the token on the client's side only. In that case, if the user requests a route from the server, which needs to consume a protected resource, this request will fail, because the server won't have recieved the user's token.

One way to solve this problem is persist the token in a Cookie instead of the localStorage.

./src/boot/axios.js

import axios from 'axios'

const axiosInstance = axios.create({
  baseURL: 'https://api.example.com'
})

export default function ({ Vue, ssrContext }) {
  const cookies = process.env.SERVER
    ? Cookies.parseSSR(ssrContext)
    : Cookies

  axiosInstance.interceptors.request.use(config => {
    let token = cookies.get('token')
    if (token) {
      config.headers.Authorization = `bearer ${token}`
    }
    return config;
  }, error => {
    return Promise.reject(error)
  })

  Vue.prototype.$axios = axiosInstance
}

export { axiosInstance }

After doing this, you'll probably want to test the application locally. And, more than likely, the app will work flawlessly. So you'll continue to do some integrations tests, and there you'll have success. Now confident of your app's cookie system for authenticaion, you'll publish a new version of your app and it will work correctly in 99.9% of the requests.

But, for some strange reason, users will complain about a bug, where sometimes they see things from other users, which they actually shouldn't. We have a big security issue.

2 - The Problem

You had only one instance of axios, which is shared between all requests, and each request will call the boot function and will register a new interceptor.

Since the interceptors are overriding the header, the app will use the token from the user who made the last request. Because of that, if two users make a request at the same time, both will use the same token. And even worse, an unauthorized user could get access to a protected route. In that case, the app will use the token from the last authorized user, who made a request and this is really, really bad.

3 - The Solution

So, let's recapitulate the last line of the docs regarding the use of the Cookie Plugin in an SSR app.

For server-side rendering we want the same: each request should have a fresh, isolated app instance so that there is no cross-request state pollution. So Cookies needs to be bound to each request separately.

Since the axios instance had the Cookie Plugin as a dependency, we'll noew need to bind a new axios instance to each request.

./src/boot/axios.js

import Vue from 'vue'
import axios from 'axios'

Vue.mixin({
  beforeCreate () {
    const options = this.$options
    if (options.axios) {
      this.$axios = options.axios
    } else if (options.parent) {
      this.$axios = options.parent.$axios
    }
  }
})

export default function ({ app, ssrContext }) {
  let instance = axios.create({
    baseURL: 'https://api.example.com'
  })

  const cookies = process.env.SERVER
    ? Cookies.parseSSR(ssrContext)
    : Cookies

  instance.interceptors.request.use(config => {
    let token = cookies.get('token')
    if (token) {
      config.headers.Authorization = `bearer ${token}`
    }
    return config;
  }, error => {
    return Promise.reject(error)
  })

  app.axios = instance
}

With the above code, you can safely use the $axios instance in your components, but what about vuex's stores and navigation guards?

4 - Vuex's Stores

The scope of the mutations, actions and getters of a vuex's store and your modules is the store itself. So, if we need to access the axios instance, we just need to append this to the store.

./src/boot/axios.js

import Vue from 'vue'
import axios from 'axios'

Vue.mixin({/*...*/})

export default function ({ app, store, ssrContext }) {
  let instance = axios.create(/*...*/)

  // cookies and interceptors

  app.axios = instance
  store.$axios = instance
}

and furthermore in the store....

export default {
  namespaced: true,
  state () {
    return {
      field: ''
    }
  },
  mutations: {
    field (state, value) { state.field = value }
  },
  actions: {
    async doSomething({ commit }) {
      let { value } = await this.$axios.get('endpoint_url')
      commit('field', value)
    }
  }
}

5 - Navigation Guards

Like Vuex's store, we'll need to append the axios instance to the router.

./src/boot/axios.js

import Vue from 'vue'
import axios from 'axios'

Vue.mixin({/*...*/})

export default function ({ app, store, router, ssrContext }) {
  let instance = axios.create(/*...*/)

  // cookies and interceptors

  app.axios = instance
  store.$axios = instance
  router.$axios = instance
}

But, unfortunately the router isn't in the scope of the navigation guards, so we'll need to keep a reference to the router somewhere.

./src/router/index.js

import Vue from 'vue'
import VueRouter from 'vue-router'

import routes from './routes'

Vue.use(VueRouter)

export default function (context) {
  context.router = new VueRouter({
    scrollBehavior: () => ({ x: 0, y: 0 }),
    routes: routes,
    mode: process.env.VUE_ROUTER_MODE,
    base: process.env.VUE_ROUTER_BASE
  })

  context.router.beforeEach((to, from, next) => {
    let { router, store } = context
    let { $axios } = router
    console.log(router, store , $axios)
    next()
  })
  return context.router
}

And what about the per-route guards? Well, we'll need to make a small change in the ./src/router/routes.js that will no longer return an array of routes, but a function, which will receive the context as an argument and return an array of routes.

export default function (context) {
  const routes = [
    {
      path: '/',
      component: () => import('layouts/MyLayout.vue'),
      children: [
        { path: '', component: () => import('pages/Index.vue') }
      ],
      beforeEnter (to, from, next) {
        let { router, store } = context
        let { $axios } = router
        console.log(router, store , $axios)
        next()
      }
    }
  ]
  // Always leave this as last one
  if (process.env.MODE !== 'ssr') {
    routes.push({
      path: '*',
      component: () => import('pages/Error404.vue')
    })
  }
  return routes
}

Of course, we'll need to update the ./src/router/index.js.

import Vue from 'vue'
import VueRouter from 'vue-router'

import routes from './routes'

Vue.use(VueRouter)

export default function (context) {
  context.router = new VueRouter({
    scrollBehavior: () => ({ x: 0, y: 0 }),
    routes: routes(context),
    mode: process.env.VUE_ROUTER_MODE,
    base: process.env.VUE_ROUTER_BASE
  })
  return context.router
}

6 - Other services

Here, I have bad news, if you're using your axios instance in other services. You'll need to figure out a way to pass a reference of the axios to them, like this:

class Service {
  axios = void 0
  cookies = void 0
  constructor (axios, ssrContext ) {
    this.cookies = process.env.SERVER
      ? Cookies.parseSSR(ssrContext)
      : Cookies
    this.axios = axios
  }
  async auth ({ username, password }) {
    let { data: token } = this.axios.post('auth_url', { username, password })
    this.cookies.set('token', token)
  }
}

export default function ({ app, ssrContext }) {
  let service = new Service(app.axios, ssrContext)
}

7 - Simplified Injection

If you don't want to repeat your self a lot, you can create an injection helper like this:

import Vue from 'vue'

const mixins = []
const inject = function (bootCb) {
  return async function (ctx) {
    const { app, router, store } = ctx
    let boot
    if (typeof bootCb === 'function') {
      const response = bootCb(ctx)
      boot = response.then ? await response : response
    } else {
      boot = bootCb
    }

    for (const name in boot) {
      const key = `$${name}`
      if (mixins.indexOf(name) === -1) {
        mixins.push(name)
        Vue.mixin({
          beforeCreate () {
            const options = this.$options
            if (options[name]) {
              this[key] = options[name]
            } else if (options.parent) {
              this[key] = options.parent[key]
            }
          }
        })
      }
      app[name] = boot[name]
      store[key] = boot[name]
      router[key] = boot[name]
    }
  }
}

export default inject

So, modify the axios boot to use the created helper:

import axios from 'axios'
import { Cookies } from 'quasar'

export default inject(async function ({ ssrContext }) {
  let instance = axios.create({
    baseURL: 'https://api.example.com'
  })

  const cookies = process.env.SERVER
    ? Cookies.parseSSR(ssrContext)
    : Cookies

  instance.interceptors.request.use(function (config) {
    const token = cookies.get('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  }, function (error) {
    return Promise.reject(error)
  })

  return {
    axios: instance
  }
})

I hope this article will help you get your cookies under control, when working with Quasar's SSR feature. Let us know how you work with cookies or where you've had problems related to cookies and SSR and solved them. We'd love to hear about that in the comments below.

8 - About Quasar

Interested in Quasar? Here are some more tips and information:

More info: https://quasar.dev
GitHub: https://github.com/quasarframework/quasar
Newsletter: https://quasar.dev/newsletter
Getting Started: https://quasar.dev/start
Chat Server: https://chat.quasar.dev/
Forum: https://forum.quasar.dev/
Twitter: https://twitter.com/quasarframework
Donate: https://donate.quasar.dev

Discussion

pic
Editor guide
Collapse
flyingboy007 profile image
Abhilash

I changed the axios boot file like below by adding Vue.mixin instead of Vue.prototype. But now when I try to access this.$axios.post() in .vue component its throwing TypeError: Cannot read property 'post' of undefined. So the global mixin is not passing into components. I simply copied the mixin logic from here.

( didnt try the injection part as written in this post as I got stuck before
that part and trying to understand whats happening :))

import Vue from 'vue'
import axios from 'axios'
import { Cookies } from 'quasar'

Vue.mixin({
  beforeCreate () {
    const options = this.$options
    if (options.axios) {
      this.$axios = options.axios
    } else if (options.parent) {
      this.$axios = options.parent.$axios
    }
  }
})
export default function ({ app, ssrContext, store, router }) {
  const axiosInstance = axios.create({
    baseURL: 'http://lvh.me:3000',
    headers: {
      'Content-Type': 'application/json'
    }
  })
  const cookies = process.env.SERVER
    ? Cookies.parseSSR(ssrContext)
    : Cookies
  axiosInstance.interceptors.request.use(config => {
    const userString = cookies.get('user')
    if (userString) {
      // setup vuex
      store.$db().model('users').create({
        data: userString.user
      })
      // setup headers
      config.headers.Authorization = `bearer ${userString.token}`
    }
    return config
  }, error => {
    return Promise.reject(error)
  })

  axiosInstance.interceptors.response.use(
    response => response,
    error => {
      // if login route no need to reload
      if (error.response.status === 401 && router.currentRoute.path !== '/login') {
        //  store.$db().model('users').logout()
        cookies.remove('user')
        location.reload()
      }
      return Promise.reject(error)
    }
  )
  app.$axios = axiosInstance
}
Enter fullscreen mode Exit fullscreen mode

Any insights on what could be the issue here?

Collapse
tobymosque profile image
Tobias Mesquita Author

this would be:

app.axios = axiosInstance // without the `$`
Enter fullscreen mode Exit fullscreen mode
Collapse
flyingboy007 profile image
Abhilash

Thank you.

Collapse
eladc profile image
Elad Cohen

Great article! Can you please show your final implementation on Github repo?

Collapse
tobymosque profile image
Tobias Mesquita Author

really sorry, i missed your comment.:
gitlab.com/TobyMosque/quasar-couch...

Collapse
flyingboy007 profile image
Abhilash

I think this link is not for this article as this code is missing there. Could you please share the correct link if possible?

Thread Thread
tobymosque profile image
Tobias Mesquita Author

ops, i didn't created a repo to this article, mostly because this is a simplified version of what i do.

Normally, I didn't access the cookies directly, i use vuex-persistedstate as a middleware.
but u can access the Cookies directly, there is nothing wrong with this approach
github.com/TobyMosque/qpanc/blob/m...

And here the axios part:
github.com/TobyMosque/qpanc/blob/m...

I'll try to create a repo to this article at the weekend.

Thread Thread
flyingboy007 profile image
Abhilash

Ok. Thank you for the support

Collapse
omnichronous profile image
Mitko Georgiev

This guide has been invaluable, thank you. Just one thing I'm trying to puzzle out - what is the purpose of the beforeCreate() code here? Is there a reason to prefer this over injecting the Axios instance in the Vue prototype?

Collapse
tobymosque profile image
Tobias Mesquita Author

Vue.prototype is shared between all Vue instances, what will result in a single axios instance to all Vue instances (a singleton).

But in that particular case, a singleton can mess with our autentication logic, since that is being handled into the axios interceptor and this reads the token from the Cookies plugin, what isn't a singleton, what can lead to a data leak.

Collapse
mikeevstropov profile image
mikeevstropov

Thank you man!

Collapse
antalszabo profile image
AntalSzabo

Thank you for the excellent article! It would be nice to have this added to the Quasar documentation (if it is not there already), as it is rather square one if you want to implement SSR. I'd have one small remark, under 5 Navigation guards, in ./src/router/index.js, I had to switch context.router.beforeEnter to context.router.beforeEach. As far as I know, there is no global guard beforeEnter now in Vue Router, I'm not sure for older versions.

Collapse
tobymosque profile image
Tobias Mesquita Author

probably a small typo, thx for report.

Collapse
ibrainventures profile image
ibrainventures

Also a great Article and perfect understandable written.
Thank you for sharing your know how.