Concepts / Building Search UI / Server-side rendering
Aug. 06, 2019

Server-side Rendering

You are reading the documentation for Vue InstantSearch v2. Read our migration guide to learn how to upgrade from v1 to v2. You can still find the v1 documentation here.

Introduction

Server-side rendering (SSR) is a technique used to render results of a client-side framework in a server language.

Here are the main steps to implementing server-side rendering when you’re making external requests (here to Algolia):

On the server:

  1. Make a request to Algolia to get search results
  2. Render the Vue app with the results of the request
  3. Store the search results in the page
  4. Return the HTML page as a string

On the client:

  1. Read the search results from the page
  2. Render (or hydrate) the Vue app with the search results

There are different ways of making a server-side rendered app with Vue. We’ll cover how to do it with Vue CLI and with Nuxt.

With Vue CLI

First, you need to generate a Vue app with Vue CLI, then add the SSR plugin:

1
2
3
4
vue create algolia-ssr-example
cd algolia-ssr-example
vue add router
vue add @akryum/ssr

You can then start the development server by running npm run ssr:serve.

The next step is to install Vue InstantSearch:

1
npm install vue-instantsearch algoliasearch

We now need to build a Vue InstantSearch implementation without fetching data on the back end. For that, we install the plugin in src/main.js:

1
2
3
import VueInstantSearch from 'vue-instantsearch';

Vue.use(VueInstantSearch);

Then, we create a new page (src/views/Search.vue) and build a search interface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<template>
  <ais-instant-search
    :search-client="searchClient"
    index-name="instant_search"
  >
    <ais-search-box />
    <ais-stats />
    <ais-refinement-list attribute="brand" />
    <ais-hits>
      <template
        slot="item"
        slot-scope="{ item }"
      >
        <p>
          <ais-highlight
            attribute="name"
            :hit="item"
          />
        </p>
        <p>
          <ais-highlight
            attribute="brand"
            :hit="item"
          />
        </p>
      </template>
    </ais-hits>
    <ais-pagination />
  </ais-instant-search>
</template>

<script>
import algoliasearch from 'algoliasearch/lite';
const searchClient = algoliasearch(
  'latency',
  '6be0576ff61c053d5f9a3225e2a90f76'
);

export default {
  data() {
    return {
      searchClient
    };
  }
};
</script>

We then add a route to this page in src/router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import Vue from 'vue';
import Router from 'vue-router';
import Home from './views/Home.vue';
import Search from './views/Search.vue';

Vue.use(Router);

export function createRouter() {
  return new Router({
    mode: 'history',
    base: process.env.BASE_URL,
    routes: [
      /* ... */
      {
        path: '/search',
        name: 'search',
        component: Search
      }
    ]
  });
}

Also, we update the header in src/App.vue:

1
2
3
4
5
6
7
8
9
10
<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link> |
      <router-link to="/search">Search</router-link>
    </div>
    <router-view />
  </div>
</template>

For styling, we use instantsearch.css in public/index.html:

1
2
3
4
5
6
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/instantsearch.css@7.3.1/themes/algolia-min.css"
  integrity="sha256-HB49n/BZjuqiCtQQf49OdZn63XuKFaxcIHWf0HNKte8="
  crossorigin="anonymous"
/>

Our InstantSearch code uses ES modules, yet it needs to be executed in Node.js. For that reason, we need to let Vue CLI know that those files should be transpiled for a Node usage. For that, we add the following configuration to vue.config.js:

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = {
  pluginOptions: {
    ssr: {
      nodeExternalsWhitelist: [
        /\.css$/,
        /\?vue&type=style/,
        /vue-instantsearch/,
        /instantsearch.js/
      ]
    }
  }
};

At this point, Vue is rendering the app on the server, but when you go to /search in your browser, you won’t see the search results on the page. That’s because, by default, Vue InstantSearch only starts searching and showing results once the page is rendered for the first time.

To perform searches on the back end as well, we need to create a back end instance in src/main.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import VueInstantSearch, { createInstantSearch } from 'vue-instantsearch';
import algoliasearch from 'algoliasearch/lite';

const searchClient = algoliasearch(
  'latency',
  '6be0576ff61c053d5f9a3225e2a90f76'
);

export async function createApp({
  beforeApp = () => {},
  afterApp = () => {}
} = {}) {
  const router = createRouter();

  // provide access to all components
  Vue.use(VueInstantSearch);

  // provide access to the SSR functions in your app
  const { instantsearch, rootMixin } = createInstantSearch({
    searchClient,
    indexName: 'instant_search'
  });

  await beforeApp({
    router,
    // provide access to the instance from beforeApp
    instantsearch
  });

  const app = new Vue({
    // provide access to the instance
    mixins: [rootMixin],
    router,
    render: h => h(App)
  });

  const result = {
    app,
    router,
    // provide access to the instance from afterApp
    instantsearch
  };

  await afterApp(result);

  return result;
}

The Vue app can now inject the back end instance of Vue InstantSearch.

Next, we need to set up the asyncData hook with the findResultsState function that InstantSearch provides. In our case, we need to do this in src/views/Search.vue, which is the root component that needs to prefetch results.

In that same file, we need to replace ais-instant-search with ais-instant-search-ssr. We can also remove its props since they are now passed to the createInstantSearch function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
  <ais-instant-search-ssr>
    <!-- ... -->
  </ais-instant-search-ssr>
</template>

<script>
export default {
  asyncData({ instantsearch }) {
    return instantsearch.findResultsState({
      // find out which parameters to use here using ais-state-results
      query: 'iphone',
      hitsPerPage: 5,
      disjunctiveFacets: ['brand'],
      disjunctiveFacetsRefinements: { brand: ['Apple'] }
    })
  }
}
</script>

The next step is necessary to make sure that we execute this query on the back end. In src/entry-server.js, we add code to find the root component where we call asyncData. Then the InstantSearch state gets added to context using the getState function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { createApp } from './main';

export default context => {
  return new Promise(async (resolve, reject) => {
    // read the provided instance
    const { app, router, instantsearch } = await createApp();

    router.push(context.url);

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      // find the root component which handles the rendering
      Promise.all(
        matchedComponents.map(Component => {
          if (Component.asyncData) {
            return Component.asyncData({
              // provide access to the instance from asyncData
              instantsearch,
              route: router.currentRoute
            });
          }
        })
      )
        .then(() => {
          // save the state of this search to the context
          context.algoliaState = instantSearch.getState();
        })
        .then(() => resolve(app));
    }, reject);
  });
};

Finally, we rehydrate the app with the initial request once we start searching. For this, we need to make sure we save the data on the page. Vue CLI provides a way to do this, allowing us to read the value on context. We can save it in public/index.html:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
  <body>
    <!--vue-ssr-outlet-->
    {{{ renderState() }}}
    {{{ renderState({ contextKey: 'algoliaState', windowKey: '__ALGOLIA_STATE__' }) }}}
    {{{ renderScripts() }}}
  </body>
</html>

In src/entry-client.js we add the necessary code to read from window.__ALGOLIA_STATE__ and pass on the data to Vue InstantSearch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { loadAsyncComponents } from '@akryum/vue-cli-plugin-ssr/client';

import { createApp } from './main';

createApp({
  async beforeApp({ router, instantsearch }) {
    // read from the serialized state and pass it to the instance
    instantsearch.hydrate(window.__ALGOLIA_STATE__);
    delete window.__ALGOLIA_STATE__;

    await loadAsyncComponents({ router });
  },

  afterApp({ app }) {
    app.$mount('#app');
  }
});

That’s it! You can find the source code on GitHub.

With Nuxt

The process of enabling server-side rendering with Nuxt is mostly the same as with Vue CLI. The main difference is that Nuxt directly handles some parts.

The first step is to generate a Nuxt app and adding vue-instantsearch:

1
2
3
npx create-nuxt-app algolia-nuxt-example
cd algolia-nuxt-example
npm install vue-instantsearch algoliasearch

Our InstantSearch code uses ES modules, yet it needs to be executed in Node.js. For that reason, we need to let Nuxt know that those files should be transpiled for a Node usage. For that, we add the following configuration to nuxt.config.js:

1
2
3
4
5
module.exports = {
  build: {
    transpile: ['vue-instantsearch', 'instantsearch.js/es'],
  },
};

We then create a new page (pages/search.vue) and build a Vue InstantSearch interface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<template>
  <ais-instant-search :search-client="searchClient" index-name="instant_search">
    <ais-search-box />
    <ais-stats />
    <ais-refinement-list attribute="brand" />
    <ais-hits>
      <template
        slot="item"
        slot-scope="{ item }"
      >
        <p>
          <ais-highlight
            attribute="name"
            :hit="item"
          />
        </p>
        <p>
          <ais-highlight
            attribute="brand"
            :hit="item"
          />
        </p>
      </template>
    </ais-hits>
    <ais-pagination />
  </ais-instant-search>
</template>

In the script of this component, we add components declarations and the stylesheet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import {
  AisInstantSearch,
  AisRefinementList,
  AisHits,
  AisHighlight,
  AisSearchBox,
  AisStats,
  AisPagination,
  createInstantSearch
} from 'vue-instantsearch';
import algoliasearch from 'algoliasearch/lite';

const searchClient = algoliasearch(
  'latency',
  '6be0576ff61c053d5f9a3225e2a90f76'
);

export default {
  components: {
    AisInstantSearch,
    AisRefinementList,
    AisHits,
    AisHighlight,
    AisSearchBox,
    AisStats,
    AisPagination
  },
  data() {
    return {
      searchClient
    };
  },
  head() {
    return {
      link: [
        {
          rel: 'stylesheet',
          href:
            'https://cdn.jsdelivr.net/npm/instantsearch.css@7.3.1/themes/algolia-min.css'
        }
      ]
    };
  }
};

Then, we add:

  1. createInstantSearch to create a reusable search instance
  2. findResultsState in asyncData to perform a search query in the back end

On the front end, Nuxt merges anything that is returned from asyncData with data. This calls the hydrate method, but only in the client (via beforeMount). The effect it has is that the search parameters we add for the back end search will also be shown if you navigate to the page regularly. This is something that Vue SSR does not natively do.

Finally, we need to replace ais-instant-search with ais-instant-search-ssr, and add the rootMixin to provide the instance to the component.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
<template>
  <ais-instant-search-ssr>
    <ais-search-box />
    <ais-stats />
    <ais-refinement-list attribute="brand" />
    <ais-hits>
      <template
        slot="item"
        slot-scope="{ item }"
      >
        <p>
          <ais-highlight
            attribute="name"
            :hit="item"
          />
        </p>
        <p>
          <ais-highlight
            attribute="brand"
            :hit="item"
          />
        </p>
      </template>
    </ais-hits>
    <ais-pagination />
  </ais-instant-search-ssr>
</template>

<script>
import {
  AisInstantSearchSsr,
  AisRefinementList,
  AisHits,
  AisHighlight,
  AisSearchBox,
  AisStats,
  AisPagination,
  createInstantSearch
} from 'vue-instantsearch';
import algoliasearch from 'algoliasearch/lite';

const searchClient = algoliasearch(
  'latency',
  '6be0576ff61c053d5f9a3225e2a90f76'
);

const { instantsearch, rootMixin } = createInstantSearch({
  searchClient,
  indexName: 'instant_search'
});

export default {
  asyncData() {
    return instantsearch
      .findResultsState({
        // find out which parameters to use here using ais-state-results
        query: 'iphone',
        hitsPerPage: 5,
        disjunctiveFacets: ['brand'],
        disjunctiveFacetsRefinements: { brand: ['Apple'] }
      })
      .then(() => ({
        instantSearchState: instantsearch.getState()
      }));
  },
  beforeMount() {
    instantsearch.hydrate(this.instantSearchState);
  },
  mixins: [rootMixin],
  components: {
    AisInstantSearchSsr,
    AisRefinementList,
    AisHits,
    AisHighlight,
    AisSearchBox,
    AisStats,
    AisPagination
  },
  head() {
    return {
      link: [
        {
          rel: 'stylesheet',
          href:
            'https://cdn.jsdelivr.net/npm/instantsearch.css@7.3.1/themes/algolia-min.css'
        }
      ]
    };
  }
};
</script>

Did you find this page helpful?