Wood Chen

Solving Vue SPA SEO with Lightweight Placeholder Replacement in Go

0 comments57 views1.1k words

This post was translated from Chinese by AI. If anything reads oddly, the Chinese original is authoritative. 中文原文

github: https://github.com/woodchen-ink/aimodels-prices

How It Started

I have a Vue 3 + Go (Gin) project—an AI model pricing aggregator (URL: https://ai-prices.sunai.net/ ). Everything works fine, but one thing has always bothered me: poor search engine indexing.

I didn't really care at first, since it was just for making API calls for One hub. Today, I looked up a model's price by searching Google for 模型名 price, and found that some third-party sites showed up too.

But when I opened Google Search Console for my own site, every page had the title "AI Model Pricing Aggregator" and exactly the same description. To search engines, the /prices and /providers pages looked identical.

This is actually a common problem with all Vue/React single-page applications.

What's the Problem?

Here's how an SPA works: the server always returns the same index.html, then the frontend JS takes over routing and rendering.

Many tutorials tell you to dynamically update document.title and meta tags in Vue Router's afterEach hook:

router.afterEach((to) => {
  document.title = to.meta.title || '默认标题'
  document.querySelector('meta[name="description"]')
    .setAttribute('content', to.meta.description)
})

This works perfectly for users—the browser tab title does change. But most search engine crawlers only see the raw HTML returned by the server; they don't execute JavaScript. So they always see the meta information hardcoded in index.html.

Google's crawler can execute JS, but there's a delay, and rendering isn't guaranteed every time. Baidu's crawler is even worse: it basically doesn't execute JS.

Common Solutions and Their Costs

These are the usual approaches to SPA SEO:

  • SSR (Nuxt.js / Next.js): The best results, but it requires rewriting the entire frontend—too much work
  • Prerendering (Prerender): Generates static HTML at build time, but builds get slow with many pages, and it can't handle dynamic content
  • Third-party services like Prerender.io: Check the User-Agent and return prerendered pages to crawlers, but they cost money and add another component to maintain

My project only has four or five pages. SSR would be overkill, and prerendering would mean adding new dependencies. Then I realized: my backend already handles the SPA fallback—every request that isn't for a static file returns index.html. Could I tweak it before returning it?

Prerequisite: My Project Architecture Happens to Fit

There's one key prerequisite for this approach: after the frontend is built into static files, the Go backend handles all routes serving them.

Here's how I deploy it: running npm run build in the Vue project produces a dist/ directory (a bunch of JS, CSS, and an index.html), and I place these static files alongside the Go service. Go's Gin framework uses NoRoute to handle all non-API requests—if the request points to an existing static file (JS, CSS, or an image), it returns that file directly; otherwise, it returns index.html and lets the frontend router take over.

r.NoRoute(func(c *gin.Context) {
    // Return 404 for API requests
    if strings.HasPrefix(c.Request.URL.Path, "/api") {
        c.JSON(404, gin.H{"error": "API endpoint not found"})
        return
    }

    // Return the static file directly if it exists
    path := filepath.Join(staticDir, c.Request.URL.Path)
    if info, err := os.Stat(path); err == nil && !info.IsDir() {
        c.File(path)
        return
    }

    // Return index.html for all other requests (SPA fallback)
    seo.RenderIndex(c, staticDir)
})

The entire project runs as a single Go process, serving both the API and static files. No Nginx, no Node process. This means every page request passes through Go code—and that's where I can make the changes.

If your frontend is hosted separately on Nginx or a CDN, this approach won't work, because requests never reach your backend. But if you're also using this "Go handles everything" deployment setup, it's a perfect fit.

My Approach: Backend Placeholder Replacement

The idea is simple:

  1. Use placeholders in index.html instead of hardcoded meta content
  2. When the Go backend returns index.html, replace the placeholders with content based on the request path
  3. Keep the frontend Router's afterEach hook to handle updates during navigation within the SPA

Together, these two layers cover both crawlers and users.

Step 1: Add Placeholders to index.html

<meta name="description" content="{{SEO_DESCRIPTION}}">
<meta name="keywords" content="{{SEO_KEYWORDS}}">
<link rel="canonical" href="{{SEO_CANONICAL}}">
<meta property="og:title" content="{{SEO_TITLE}}">
<meta property="og:description" content="{{SEO_DESCRIPTION}}">
<meta property="og:url" content="{{SEO_CANONICAL}}">
<title>{{SEO_TITLE}}</title>

Just replace the hardcoded content with markers like {{SEO_TITLE}} and {{SEO_DESCRIPTION}}. Any format works, as long as it doesn't conflict with normal content.

Step 2: Define SEO Data for Each Page in the Go Backend

Create seo/seo.go:

package seo

type PageMeta struct {
    Title       string
    Description string
    Keywords    string
}

var routeMeta = map[string]PageMeta{
    "/": {
        Title:       "AI模型价格 - AI模型价格汇总",
        Description: "汇总 OpenAI、Claude、Gemini 等几十家模型的价格...",
        Keywords:    "AI模型价格,GPT价格,Claude价格",
    },
    "/prices": {
        Title:       "价格列表 - AI模型价格汇总",
        Description: "查看和对比各大AI模型的详细价格信息...",
        Keywords:    "AI模型价格列表,Token价格",
    },
    // ... Other pages
}

A map with paths as keys and SEO information as values. Simple and straightforward.

Step 3: Replace Strings Before Returning index.html

func RenderIndex(c *gin.Context, staticDir string) {
    tmpl := loadTemplate(staticDir)  // Read once, cached with sync.Once

    path := c.Request.URL.Path
    meta, ok := routeMeta[path]
    if !ok {
        meta = defaultMeta  // Use homepage metadata if no match is found
    }

    html := tmpl
    html = strings.ReplaceAll(html, "{{SEO_TITLE}}", meta.Title)
    html = strings.ReplaceAll(html, "{{SEO_DESCRIPTION}}", meta.Description)
    html = strings.ReplaceAll(html, "{{SEO_KEYWORDS}}", meta.Keywords)
    html = strings.ReplaceAll(html, "{{SEO_CANONICAL}}", baseURL+path)

    c.Header("Content-Type", "text/html; charset=utf-8")
    c.String(http.StatusOK, html)
}

index.html is read from disk only once using sync.Once. All subsequent requests use the in-memory copy, so the performance overhead is practically zero. strings.ReplaceAll replaces four placeholders—no regex, no template engine, just plain string operations.

Step Four: Change One Call

The SPA fallback in main.go originally was:

c.File(indexPath)  // Return the static file directly

Change it to:

seo.RenderIndex(c, staticDir)  // Return after replacement

Done.

Frontend Coordination

Vue Router's afterEach stays unchanged. It updates the browser tab title and meta tags when users navigate within the site—no new HTML request is made at that point. It's entirely client-side, so the backend replacements don't apply here.

Each layer handles its own job without conflict.

Results

Now, request different pages directly with curl:

curl https://ai-prices.sunai.net/
# <title>AI Model Prices - AI Model Pricing Overview</title>

curl https://ai-prices.sunai.net/prices
# <title>Price List - AI Model Pricing Overview</title>

curl https://ai-prices.sunai.net/providers
# <title>Model Providers - AI Model Pricing Overview</title>

The HTML returned for each page contains the correct title, description, OG tags, and canonical URL for that page. Search engines get the right information.

Extras I Added Along the Way

Since I was working on SEO, I went ahead and covered the other essentials:

  • Open Graph tags: Correct preview cards when sharing on social platforms
  • Twitter Card tags: Same as above
  • JSON-LD structured data: Helps search engines better understand the type of website
  • robots.txt: Tells crawlers what to crawl and what to skip (for example, /login and /api/ don't need to be indexed)
  • sitemap.xml: Gives crawlers a sitemap

Pros and Cons of This Approach

Pros:

  • Zero dependencies—no npm packages or Go libraries to install
  • Minimal changes: one frontend file and two backend files
  • Good performance: sync.Once reads the file once, and all subsequent string replacements happen in memory
  • Leaves the existing architecture intact; write Vue code as usual

Limitations:

  • SEO data is hardcoded in Go, so adding a page requires code changes and redeployment
  • Only suitable for a manageable number of pages. A few dozen pages are fine, but a blog with thousands of posts still needs SSR or prerendering

For this small project with four or five pages, it's just right.

Summary

The SEO problem with SPAs comes down to one thing: the HTML crawlers receive isn't what users see.

The solution is straightforward too: make sure the HTML crawlers receive already contains the correct information. SSR is the most comprehensive solution, but for projects with only a few pages, a simple layer of string replacement on the backend is enough.

Not every problem needs a "perfect" solution. Good enough is enough.

#Vue #SPA #SEO #Go #FrontendBackendSeparation

Related posts

Comments 0