Building an SEO-friendly responsive i18n website using Vite-SSG + Vuetify3
Tags: webdev, vue, seo, tutorial
Published: 2024-03-17 14:05:02

At the end of 2023, I finally decided to renovate my [UtilMeta website](https://utilmeta.com). The main reason was that the previous official website was a SPA application made by Vue2, which was not friendly to SEO and social media sharing, a hard issue for the official website aiming to introduce the project.

So after a round of research, I used **Vite-SSG** + **Vue3** + **Vuetify3** to refactor my website again. It took me about two weeks. This article records the thinking and summary of the development process. which covers:

* Why SPA shouldn't be used to build a project homepage?
* What is the structure of the SSG project and how to configure the routing of the page?
* How to build a multilingual static site, write multilingual page components, and use `lang` and `hreflang` to specify different language versions for the page?
* How to use the `unhead` library to configure different HTML headers for each page to optimize search engine index and social media sharing?
* How to use CSS `@media` rules to handle the first screen loading of responsive pages on different devices?
* How to handle the 404 problem gracefully and avoid the impact of soft 404 on SEO?

## Why not SPA?

Here we first narrow down the definition, defining **official website / homepage** as an introductory website with an introduction page, pricing plans, about us, etc., rather than a dynamic product with direct interaction (such as various UGC social media platforms). For dynamic products, it’s okay to use SPA, and if you want to optimize search, you can regularly submit some fixed profile pages or article pages to the search engine.

So that’s one reason. **SEO**. It’s a cliche that SPAs generally only generate a single `index.html` , and crawling any URL on your site will only return the same content, which often does not include the text, keywords and links that will be rendered, which leads to a mess of search engine results. In Twitter, Discord and other social media platforms that directly grab link meta-information (title, description, illustration) and render it, each of your web pages will only present the same information.

For a project that needs to get customers on the Internet, we should not ignore the traffic from search engines, especially international projects. Even if we come to the AIGC era, LLM like ChatGPT still focuses on crawling web pages for training. At this time, if each page of your project can provide clear HTML results that contain enough accurate keywords and information and conform to Web specifications, Your project or document may also be included by AI and integrated into their output, so I think the optimization of web page structure and rendering can be collectively referred to as **Agent Optimization**, that means, web page optimization for search engines or LLMs is still very important.

### What is the appropriate way?

**SSR** (server-side rendering) or **SSG** (server-side generation) are suitable approaches for introductory official website development. For static pages that do not require too much rendering logic, SSG is enough. You just need to throw the generated HTML on any page hosting website or CDN to provide direct access. If you like to take control of hosting, you can also make your own server to deploy. I personally use Nginx to deploy the static pages generated by SSG as the source of CDN.

## SSG project structure

Compared with the SPA application, the main difference between the SSG project is that the route and the corresponding page template are fixed, and the HTML file of each page will be generated directly in the building phase, instead of generating only one like SPA.

Reflected in the file structure of the Vue project, SPA applications often need a router file to define the routes of the `vue-router` and the corresponding components. SSG applications, on the other hand, can define the route of each page and the corresponding Vue page component directly in a folder (often named `pages`).

So the `main.js` of Vite-SSG project generally looks like this:
```js
import App from './App.vue'
import { ViteSSG } from 'vite-ssg'
import routes from '~pages';
import vuetify from './plugins/vuetify';

export const createApp = ViteSSG(
  App,
  // vue-router options
  {routes, scrollBehavior: () => ({ top: 0 }) },
  // function to have custom setups
  ({ app, router, routes, isClient, initialState }) => {
    // install plugins etc.
    app.use(vuetify)
  },
)
```

Instead of the Vue default `createApp`, we use the Vite-SSG defined `ViteSSG`, and when importing the route, we use the

```js
import routes from '~pages';
```

In the support of the `vite-plugin-pages` plugin. You can directly convert the Vue component under a folder into the corresponding page routes, which only need to be configured in `vite.config.js`.

```js
// Plugins
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import Pages from 'vite-plugin-pages'

export default defineConfig(
  ({command, mode}) => {
    return {
      plugins: [
        Pages({
          extensions: ['vue', 'md'],
        }),
        ...
      ],
      ...
    }
  }
)
```

## Handle multi-language pages

If your official website needs to introduce your project to users from all over the world, multi-language is basically a necessary option. We take supporting Chinese and English as an example, and other language support methods can be analogized.

In the past, my personal method to handle multiple languages was to set the language according to the Geo-IP location, which was not reflected on the URL, and this is actually a **Bad Practice**. It is completely uncontrollable for what language version of the page users see when they visit (because they may use a proxy). And so does his audience when a user shares a page, and search engines can’t fully crawl all language versions (because Google’s crawlers are mainly in the United States), there is also a [Docs of Google](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites) stating that it is not recommended to do so.

For SSG page routing, my multilingual implementation practice is to implement one **General page component** for each page, in which a `lang` prop is defined, and all the text displayed in the component can select the corresponding language version according to this `lang` prop. Since the props of the page are directly passed in during SSG construction, HTML page files in different language versions will be generated. A simplified example of the page component is as follows

```html
<script setup>
const props = defineProps({
  lang: {
    type: String,
    default() {
      return 'en'
    }
  },
})

const messages = {
  zh: {
    title: '构建数字世界的基础设施'
  },
  en: {
    title: 'Building the infrastructure of the digital world'
  },
}

const msg = messages[props.lang];
</script>

<template>
  <div>
    {{ msg.title }}
  </div>
</template>
```

Next, we can build the folder structure of our multilingual page. You can choose to use different languages as different sub-routes, such as
```
/pages
	/en
		index.vue
	/zh
		index.vue
	/ja
		index.vue
	/..
```
In this way, visiting `/en` will enter the English page, and the visit `/zh` will enter the Chinese page.

Another option is to select a language as the default, such as English, and place its child routes parallel to the other language directories, such as
```
/pages
	/zh
		index.vue
	/ja
		index.vue
	index.vue     # en
```

My website [utilmeta.com](https://utilmeta.com) used the second approach because I want to make the domain name of the official website directly accessible and linked and keep it simple, so I plan its routing in this way.

```
/pages
	/zh
		index.vue ------ Index homepage  (Chinese)
		about.vue ------ About us        (Chinese)
		solutions.vue -- Solutions page  (Chinese)
		py.vue --------- Introduction of UtilMeta framework (Chinese)
	index.vue    ------- Index homepage  (English)
	about.vue ---------- About us        (English)
	solutions.vue ------ Solutions page  (English)
	py.vue ------------- Introduction of UtilMeta framework (English)
```

By JavaScript convention, `index.vue` it is treated as a route consistent with its directory, and other names are assigned routes based on the name

The locale page component only needs to set the `lang` prop of the general page component to the corresponding language code, such as the Chinese version of the “About Us” page `/zh/about.vue` 

```html
<script setup>
import About from "@/views/About.vue";
import AppWrapper from "@/components/AppWrapper.vue";
</script>

<template>
  <AppWrapper lang="zh" route="about">
     <About lang="zh"></About>
  </AppWrapper>
</template>
```

The `@/views/About.vue` is the general component of the “About Us” page, which we passed in `lang="zh"`, and `AppWrapper` is a general page skeleton component I wrote, which contains the top bar, bottom bar, sidebar and other page structures that every page needs.

### Language switching

For official websites that support multiple languages, we can add a button that allows users to actively switch languages. Its logic is also very simple. It only needs to display a list of supported languages for users, and then each language button can switch users to the corresponding page route, such as

```html
<template>
	<v-menu open-on-click>
	  <template v-slot:activator="{ props }">
		<v-btn v-bind="props">
		  <v-icon>mdi-translate</v-icon>
		</v-btn>
	  </template>
	  <v-list color="primary">
		<v-list-item
          v-for="(l, i) in languages"
          :to="getLanguageRoute(l.value)"
          :active="lang === l.value"
          :key="i"
        >
          <v-list-item-title>{{ l.text }}</v-list-item-title>
        </v-list-item>
	  </v-list>
	</v-menu>
</template>

<script setup>
  const props = defineProps({
    lang: {
      type: String,
      default(){
        return 'en'
      }
    },
    route: {
      type: String,
      default(){
        return ''
      }
    }
  });

  const languages = [{
    value: 'en',
    text: 'English'
  }, {
    value: 'zh',
    text: '中文'
  }];
	
  function getLanguageRoute(l){
    if(l === 'en'){
      return '/' + props.route;
    }
    if(!props.route){
      return `/${l}`
    }
    return `/${l}/` + props.route
  }
</script>
```

Take the About page above as an example. If the user is currently in the [https://utilmeta.com/about](https://utilmeta.com/about) route (English) and clicks on the **Chinese** language, he will be guided to the [https://utilmeta.com/zh/about](https://utilmeta.com/zh/about) page. From the user’s point of view, the structure of the page is exactly the same, but the language is switched from English to Chinese.

## Inject HTML headers into the page

For static pages, the meta information in the header tag `<head>`  are very important, which determines the index and keywords included by search engines, as well as the information rendered by page links when shared on social media. Generally speaking, the page components of Vue are only elements in writing `<body>`. But just by using a library called `unhead`, you can write different headers for different pages. For example, the following is the meta information I wrote in the page component of the UtilMeta English homepage.

```html
<script setup>
import { useHead } from '@unhead/vue'

const title = 'UtilMeta | Full-lifecycle DevOps solution for backend APIs'
const description = 'Power the builders with full-lifecycle solution for backend apps and APIs, ' +
  'our products includes UtilMeta Python framework - a progressive meta backend framework, API console and utype'

useHead({
  title: title,
  htmlAttrs: {
    lang: 'en'
  },
  link: [
    {
      hreflang: 'zh',
      rel: 'alternate',
      href: 'https://utilmeta.com/zh'
    }
  ],
  meta: [
    {
      name: 'description',
      content: description,
    },
    {
      property: 'og:title',
      content: title
    },
    {
      property: 'og:image',
      content: 'https://utilmeta.com/img/index.png'
    },
    {
      property: 'og:description',
      content: description
    }
  ],
})

import Index from '@/views/Index.vue'
import AppWrapper from "@/components/AppWrapper.vue";

</script>

<template>
  <AppWrapper lang="en">
    <Index lang="en"></Index>
  </AppWrapper>
</template>
```

The important attributes are:

* `title`: the page title that the user sees in the browser and the title included in the search engine index.
* `htmlAttrs.lang` You can edit the value of the language attribute `lang` directly in the `html` root element
* `hreflang`: You can specify different language versions of a page by inserting a `<link>` element with a `hreflang` attribute. Here we specify a link to the Chinese version of the homepage. Such an attribute can better facilitate the multilingual presentation of search engines.
* `meta.description`: description in the meta tag, will be displayed as description in social media sharing.
* `og:*`: specified by the [Open Graph Protocol](https://ogp.me/) for social media links rendering that determine their title, description, and images when you share them on social media or chat software such as Twitter(X), Discord, etc.

headers should be injected at the page level, for pages in different languages, you should also inject headers in that language version.

## Responsive in SSG

Of course, you want your website to look good (or at least not have overlap elements) on a widescreen computer, tablet, or phone, and to do that, you need to develop responsive web pages.

I personally use **Vuetify** to develop the UtilMeta official website, which has provided a Display system and breakpoints mechanism, which allows us to specify different display effects for different devices during development

![vuetify breakpoints](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/taoe72b8rzidsgr1hcsr.png)

For example

```html
<v-row>
	<v-col :cols="display.xs.value ? 12 : 6">
	</v-col>
	<v-col :cols="display.xs.value ? 12 : 6">
	</v-col>
<v-row>
```

In this way, you can adjust the display of content on devices of different sizes through rows and columns, as shown in


![responsive with grid](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/25mrfmw87h67zqj8o1vv.png)



### Problems with v-if Syntax

Everything looks good, right? You find that you can really be responsive when debugging locally, but when the site goes live, you will find problems:

When the web page is loaded at first, it will also keep the style of the mobile side by default. It will not be adjusted to the appropriate style according to the screen size until the JS is loaded. In this way, when loading or refreshing, the user will see that the elements of the web page jumped in a few seconds. This is a very strange experience, so why does it cause such a problem?

After I opened the HTML generated by Vite-SSG, I found that SSG will directly freeze and render the configuration in the template when it is generated. For the responsive code similar to the following

```html
<v-col :cols="display.xs.value ? 12 : 6">
  <h1 :style='{fontSize: display.xs.value ? "32px" : "48px"}'></h1>
</v-col>
```

In fact, when it is built into an HTML file, it will be rendered into

```html
<div class="v-col-12">
  <h1 :style="font-size: 32px"></h1>
</div>
```

The rendering script will directly process `display.xs.value` (and other responsive conditions) as true, resulting in an HTML file with fixed styles, so the user can only re-render according to the size of the device when the responsive JavaScript code is loaded. It will cause the problem of transient element jump.

### Saved by `@media`

So how do you properly handle the responsive styling of static pages? The answer I’ve explored is to use CSS `@media` rules, which allow you to create different style rules based on the size of the screen so that your responsive style **fully controlled by CSS** will render exactly according to the CSS rules when the page is rendered (thus dependent CSS is loaded). When different devices are refreshed, the rendering results adapted to the size of the corresponding device will also be presented directly, and the problem of element jump will not occur.

For example, I added a `about-title` class to the title of the About page and write it in the corresponding CSS.
```css
  .about-title{
    font-size: 60px;
    line-height: 72px;
    max-width: 800px;
    margin: 6rem auto 0;
  }

  @media (max-width: 600px){
    .about-title{
      font-size: 36px;
      line-height: 48px;
      margin: 3rem auto 0;
	}
}
```

In this way, the title of the About page can be rendered according to `@media` the style defined in the block in devices with a size of less than `600px`

**Handling v-row / v-col**

The grid system provided by Vuetify (**v-row** control row, **v-col** control column) can greatly improve the efficiency of responsive web page development, but we often need to keep the display of rows and columns responsive on different devices. However, the `@media` rules does not yet support different HTML classes for different device sizes, so how to deal with the responsiveness of the grid system in SSG applications?

The following is my practice: For a component that needs to switch the `cols` number of `v-col` on the mobile side, we can directly name the corresponding number of columns on the mobile as a class, such as

```html
<v-row>
    <v-col :cols="6" class="xs-12-col">
    </v-col>
    <v-col :cols="6" class="xs-12-col">
    </v-col>
</v-row>
```

We then use `@media` rules to specify the grid style parameters for these classes directly in the mobile-sized device, such as

```css
@media (max-width: 600px) {
	.xs-12-col{
	  flex: 0 0 100%!important;
	  max-width: 100%!important;
	}
	.xs-10-col{
	  flex: 0 0 83.3%!important;
	  max-width: 83.3%!important;
	}
	.xs-2-col{
	  flex: 0 0 16.6%!important;
	  max-width: 16.6%!important;
	}
}
```

In this way, our grid system can also support the responsive style in SSG without load jumps.

## Deploy SSG website

### Processing 404 elegantly

In the SSG static pages, the route supported by our website is pre-defined and generated, and other paths should return to 404 directly. However, in order to give users a better experience, the common practice is to make a `404 Notfound` separate page and show it to users when there is no page in the access path. Make it easy for him to go back to the home page or other pages, such as the 404 page of UtilMeta’s official website.


![404 page of utilmeta.com](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wf9tauyzikuy8eg39gg4.png)


Using Vite-SSG to achieve this effect is not difficult, you just need to add two components to the `pages` folder.

* `404.vue`
* `[...all].vue`

The contents of these two components are the same: the component code of the 404 page, `[...all].vue` will be used as the fallback return page of all page requests that do not match the route, and `404.vue` will output an explicit route `404.html` to facilitate direct redirection in nginx.

After completing our SSG page development, we can call the following command to build the page into a corresponding HTML file
```
vite-ssg build
```

For my UtilMeta website, the generated files are as follows
```
/dist
    /zh
        about.html
        py.html
        solutions.html
    404.html
    zh.html
    about.html
    index.html
    py.html
    solutions.html
```

Then, you can upload these static files to the page hosting service or build your own static server to provide access. The nginx configuration I used to build the static server of UtilMeta’s official website is as follows
```nginx
server{
    listen 80;
    server_name utilmeta.com;
    rewrite ^/(.*)/$ /$1 permanent;
    
    location ~ /(css|js|img|font|assets)/{
        root /srv/utilmeta/dist;
        try_files $uri =404;
    }
    location /{
        root /srv/utilmeta/dist;
        index index.html;
        try_files $uri $uri.html $uri/index.html =404;
    }

    error_page 404 403 500 502 503 504 /404.html;

    location = /404.html {
        root /srv/utilmeta/dist;
    }
}
```

The reason for listen port 80 instead of port 443 in the configuration is that all the static resources of my website have been hosted to the CDN (including SSL certificates). The nginx here is configured as the source server of the CDN, so it is OK to provide the HTTP access only.

The role of nginx configuration `rewrite ^/(.*)/$/$1 permanent` is to map the directory access to the corresponding HTML file access, such as mapping [https://utilmeta.com/zh/](https://utilmeta.com/zh/) to [https://utilmeta.com/zh](https://utilmeta.com/zh), otherwise Nginx will return a 403 Forbidden response.

Because the default build strategy for vite-ssg generates files located in a directory path `index.vue` as HTML files with the same name as the directory, rather than as `index.html` files placed in the directory, So if you don’t `rewrite` remove the end `/` of the path, [https://utilmeta.com/zh/](https://utilmeta.com/zh/) will access the `/zh/` directory directly, which is forbidden for nginx.

It is worth noting that the return of a 404 page should accompanied by a real **404 Response Code (Status Code)** rather than a 200 OK (that is commonly referred to as a soft 404), because the search engine will only consider the route invalid if it detects the 404 response code, especially when your site is renovated, some routes of the old site will fail, and if they remain in the search engine results to mislead users, they will also cause great trouble to visitors.

In the above nginx configuration, we attach all `try_files` instructions with `=404` at the end, to generate a 404 response code when no file is matched. Then we use `error_page` to specify the error page of common error response codes (including 404) as the `/404.html` page we wrote before, so that we can solve the problem of soft 404. All paths that cannot be matched will return the correct 404 response code and the 404 page.

## Wrap up

Summarize what we have learned and accomplished.

* Write an SSG official website project with Vite-SSG, and understand the page routing mode of the SSG project.
* Write reusable multi-language SSG page components, and language switching function through route switching.
* Use `unhead` to inject headers into each page, so that each page can be displayed correctly and beautifully on search engines and social media.
* Use `@media` to solve problems in implementing SSG responsively for static pages, and the SSG responsive practice of the Vuetify grid system.
* Elegantly handles the 404 of static pages, avoids soft 404, and improves the quality of page index and user experience.

If you find this article helpful, you can browse the [utilmeta.com](https://utilmeta.com) website I finally built in this article, or follow my [X(Twitter)](https://twitter.com/voidZXL), I will share some technical practices and projects from time to time.