Start free. Scale as your blog grows.Get started

Developer documentation

Vue

Use ContioReach from a Vue 3 single-page app, with the small API server one needs.

A Vite build has nowhere safe to keep an API key, so the official starter pairs the SPA with about a hundred lines of Express. That server also fills in the page head before the document is sent, which is what makes a Vue SPA presentable to crawlers.

Start from the official starter

contioreach/vue-starter-contioreach

A complete blog: a Vue 3 SPA plus an API server that holds the CMS key, caches reads with tags, answers the publish webhook, and server-renders the page head per route. Vue 3.5, vue-router, Express 5, Tailwind CSS v4, MIT licensed.

Terminal
npx degit contioreach/vue-starter-contioreach my-blog
cd my-blog
npm install
cp .env.example .env
# add your API key to .env, then:
npm run dev

npm run dev starts both halves at once — the API server and the Vite client. The rest of this page builds the same thing from scratch.

1. The key stays on a server

A Vite build produces static files that run in the visitor's browser. Anything those files can read is public, including environment variables — Vite inlines every VITE_-prefixed value directly into the bundle.

Never put your ContioReach API key in VITE_CMS_API_KEY or any other client-readable variable. It reads your whole workspace, and once it has shipped in a bundle it is public.

  1. Browser requests /api/listing from your own server
  2. Your server adds the X-API-Key header
  3. ContioReach returns the posts
  4. Your server caches the response and passes it on
  5. Vue renders it

In production that server serves the built client as well, so the whole app is one origin and one deployment — no CORS, no separate static host.

The server below is the same one the React starter uses, give or take the head it renders. If you are choosing between the two, choose on the frontend — the backend half is identical work either way.

2. Configuration

.env
CMS_API_URL=https://cms-api.contioreach.com
CMS_API_KEY=your_api_key_here
REVALIDATION_SECRET=your_webhook_secret
PUBLIC_SITE_URL=http://localhost:3000
PORT=3000

Read in one module, which the client build never imports. Failing at boot beats a site that 401s on its first visitor.

server/config.js
function required(name) {
  const value = process.env[name];

  if (!value) {
    throw new Error(
      `Missing required environment variable: ${name}. See .env.example.`
    );
  }

  return value;
}

export const cmsApiUrl = () => required("CMS_API_URL");
export const cmsApiKey = () => required("CMS_API_KEY");
export const revalidationSecret = () => required("REVALIDATION_SECRET");
export const PORT = Number.parseInt(process.env.PORT, 10) || 3000;

/* The values the browser is allowed to know. Served at runtime from
   GET /api/config rather than baked into the bundle, so one build artifact
   can run in staging and in production. */
export function publicConfig() {
  return {
    siteUrl: required("PUBLIC_SITE_URL"),
    noIndex: process.env.PUBLIC_ALLOW_INDEXING !== "true"
  };
}

export function assertConfig() {
  cmsApiUrl();
  cmsApiKey();
  revalidationSecret();
  publicConfig();
}

3. A cache with tags

Without a cache, every visitor costs an API call. The starter uses the same small tagged cache as the SvelteKit and Remix guides — a value is fresh for maxAge, then served stale for up to swr while one refresh runs behind the request, and invalidate(tags) drops exactly the matching entries.

server/cache.js
/* Full implementation is in the starter — about sixty lines. The interface
   is all the rest of the server needs: */

export function invalidate(tags) { /* drops every entry carrying a tag */ }

export async function cached(key, { tags, maxAge, swr }, load) {
  /* fresh → return it
     stale → return it, refresh behind the request
     cold  → await load(), sharing any in-flight promise for the same key */
}
server/cms.js
import { cached } from "./cache.js";
import { cmsApiKey, cmsApiUrl } from "./config.js";

async function apiRequest(endpoint) {
  const response = await fetch(`${cmsApiUrl()}${endpoint}`, {
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": cmsApiKey()
    }
  });

  if (!response.ok) {
    throw new Error(`API request failed: ${response.status}`);
  }

  const data = await response.json();

  // A response can arrive with HTTP 200 and success: false.
  if (!data.success) {
    throw new Error(data.error?.message || "API request failed");
  }

  return data;
}

const read = (key, tags, endpoint) =>
  cached(key, { tags, maxAge: 3600 }, () => apiRequest(endpoint));

export function getBlogs({ page = 1, limit = 12, category } = {}) {
  const params = new URLSearchParams({
    page: String(page),
    limit: String(limit),
    minimal: "true"
  });

  if (category) params.set("category", category);

  return read(`blogs:list:${params}`, ["blogs"], `/v1/blogs?${params}`);
}

export async function getPost(slug) {
  const params = new URLSearchParams({ slug, minimal: "false" });

  const result = await read(
    `blogs:by-slug:${slug}`,
    ["blogs", `blog-${slug}`],
    `/v1/blogs?${params}`
  );

  // A slug lookup returns one object, not an array.
  const post = Array.isArray(result.data) ? result.data[0] : result.data;
  return post || null;
}

The cache is per-process and in-memory. Fine for one instance; run several and a webhook clears only the one it lands on, so move the store to Redis before you scale out.

4. The server

server/index.js
import express from "express";
import { getBlogs, getPost } from "./cms.js";
import { PORT, assertConfig, publicConfig } from "./config.js";

assertConfig();

const app = express();
app.use(express.json());
app.disable("x-powered-by");

/* The in-process cache keeps these off the CMS; these headers let a CDN in
   front of the app hold the responses too. */
const cacheHeader = "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400";

// The values the browser is allowed to know. Never the CMS key.
app.get("/api/config", (_req, res) => {
  res.set("Cache-Control", "public, max-age=0, s-maxage=60").json(publicConfig());
});

app.get("/api/listing", async (req, res, next) => {
  try {
    const result = await getBlogs({
      page: Math.max(1, Number.parseInt(req.query.page, 10) || 1),
      category: req.query.category || undefined
    });

    res.set("Cache-Control", cacheHeader).json({
      posts: result.data,
      meta: result.meta
    });
  } catch (error) {
    next(error);
  }
});

app.get("/api/posts/:slug", async (req, res, next) => {
  try {
    const post = await getPost(req.params.slug);
    if (!post) return res.status(404).json({ error: "Blog post not found" });

    res.set("Cache-Control", cacheHeader).json({ post });
  } catch (error) {
    next(error);
  }
});

app.use((error, _req, res, _next) => {
  console.error("Server error:", error);
  res.status(500).json({ error: "Internal server error" });
});

app.listen(PORT, () => console.log(`Ready on http://localhost:${PORT}`));

5. Running both halves in development

In development the client runs on Vite's port and the API on its own. A proxy makes them one origin, so the app code never needs to know the difference between dev and production.

vite.config.js
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [vue()],

  build: {
    // The API server serves this directory in production.
    outDir: "dist/client",
    emptyOutDir: true
  },

  server: {
    proxy: {
      "/api": { target: "http://localhost:3000", changeOrigin: true }
    }
  }
});
package.json
{
  "scripts": {
    "dev": "concurrently -n server,client \"npm:dev:server\" \"npm:dev:client\"",
    "dev:client": "vite",
    "dev:server": "node --watch --env-file=.env server/index.js",
    "build": "vite build",
    "start": "node --env-file=.env server/index.js"
  }
}

--env-file is built into Node 20.6+, so there is no dotenv dependency here.

6. Fetching in Vue

One module is the client's only door to data. The CMS hostname and key are never in it, and never in the bundle.

src/lib/api.js
async function request(path) {
  const response = await fetch(path, { headers: { Accept: "application/json" } });

  if (!response.ok) {
    const error = new Error(`Request failed: ${response.status}`);
    error.status = response.status;
    throw error;
  }

  return response.json();
}

export const getConfig = () => request("/api/config");
export const getPost = (slug) => request(`/api/posts/${encodeURIComponent(slug)}`);

export function getListing({ page = 1, category } = {}) {
  const params = new URLSearchParams({ page: String(page) });
  if (category) params.set("category", category);
  return request(`/api/listing?${params}`);
}

And one composable covers the three states any remote data has. The run id is the part that matters: it stops a slow response for the previous page rendering over the current one.

src/lib/useAsync.js
import { onScopeDispose, ref, shallowRef, watch } from "vue";

/**
 * @param loader   () => Promise<any>
 * @param sources  reactive sources to watch; omit for a one-shot load
 */
export function useAsync(loader, sources = []) {
  const data = shallowRef(null);
  const error = shallowRef(null);
  const loading = ref(true);

  /* Incremented on every run; a resolved promise whose id no longer matches
     belongs to a navigation the user has already left behind. */
  let runId = 0;

  function run() {
    const id = ++runId;
    loading.value = true;
    error.value = null;

    loader()
      .then((result) => {
        if (id !== runId) return;
        data.value = result;
        loading.value = false;
      })
      .catch((cause) => {
        if (id !== runId) return;
        data.value = null;
        error.value = cause;
        loading.value = false;
      });
  }

  run();

  if (sources.length > 0) watch(sources, run);

  // Leaving the component invalidates anything still in flight.
  onScopeDispose(() => {
    runId += 1;
  });

  return { data, error, loading };
}

Forty lines rather than a query library, because there are only a handful of call sites. If your app grows past that, VueUse's useFetch or TanStack Query for Vue add caching and deduplication on the client as well.

7. Routes and pages

src/router.js
import { createRouter, createWebHistory } from "vue-router";
import BlogPostPage from "@/pages/BlogPostPage.vue";
import ListingPage from "@/pages/ListingPage.vue";
import NotFoundPage from "@/pages/NotFoundPage.vue";

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: "/blog", component: ListingPage },
    { path: "/blog/:slug", component: BlogPostPage },
    { path: "/:pathMatch(.*)*", component: NotFoundPage }
  ],

  /* A browser restores scroll on a real navigation; a SPA has to do it
     itself, or every new page opens halfway down where the last one was left.
     An in-page anchor is left alone. */
  scrollBehavior(to, from, savedPosition) {
    if (to.hash) return false;
    return savedPosition || { top: 0 };
  }
});

Listing

src/pages/ListingPage.vue
<script setup>
import { computed } from "vue";
import { useRoute, RouterLink } from "vue-router";
import { getListing } from "@/lib/api";
import { useAsync } from "@/lib/useAsync";

const route = useRoute();
const page = computed(() => Math.max(1, Number.parseInt(route.query.page, 10) || 1));

const { data, error, loading } = useAsync(
  () => getListing({ page: page.value }),
  [page]
);
</script>

<template>
  <main>
    <h1>Blog</h1>

    <p v-if="loading">Loading…</p>
    <p v-else-if="error">Could not load posts.</p>

    <template v-else>
      <ul>
        <li v-for="post in data.posts" :key="post.id">
          <RouterLink :to="`/blog/${post.slug}`">{{ post.title }}</RouterLink>
          <p>{{ post.excerpt }}</p>
          <time :datetime="post.publishedAt">
            {{ new Date(post.publishedAt).toLocaleDateString() }}
          </time>
        </li>
      </ul>

      <RouterLink v-if="data.meta.hasNextPage" :to="{ query: { page: page + 1 } }">
        Next page
      </RouterLink>
    </template>
  </main>
</template>

Article

src/pages/BlogPostPage.vue
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { getPost } from "@/lib/api";
import { useAsync } from "@/lib/useAsync";

const route = useRoute();
const slug = computed(() => route.params.slug);

const { data, error, loading } = useAsync(() => getPost(slug.value), [slug]);

const post = computed(() => data.value?.post ?? null);
const author = computed(() => post.value?.authors?.[0] ?? null);
</script>

<template>
  <p v-if="loading">Loading…</p>
  <p v-else-if="error">Post not found.</p>

  <article v-else-if="post">
    <h1>{{ post.title }}</h1>
    <span v-if="post.readingTime">{{ post.readingTime }} min read</span>
    <span v-if="author">By {{ author.name }}</span>

    <!-- Trusted HTML from your own workspace. -->
    <div v-html="post.content" />
  </article>
</template>

v-html renders markup without escaping it. Correct for an article body your own authors wrote, and never appropriate for user-submitted content.

8. The page head, on both sides

A client-rendered app sends crawlers an empty div. Google will usually execute the JavaScript eventually, but eventually is not a crawl budget you control — and social previews, link unfurlers and most AI answer engines do not run JavaScript at all.

The server writes the head for the first request

Because you already have a server, it can resolve the route against the same cached CMS data and fill in the head before sending index.html — real title, description, canonical, Open Graph, Twitter card and JSON-LD, on first byte.

server/index.js
/* Every non-API GET returns index.html with the head filled in for that
   route — and with the right status, because the server resolved the route
   against the CMS. */
app.get(/^\/(?!api\/).*/, async (req, res, next) => {
  try {
    const template = await readFile(path.join(clientDir, "index.html"), "utf8");
    const { head, status } = await headFor(req.path);

    res
      .status(status)
      .type("html")
      .set("Cache-Control", status === 200 ? cacheHeader : "no-store")
      .send(template.replace("<!--seo-->", head));
  } catch (error) {
    next(error);
  }
});

That also fixes the status code. Client-side routing answers HTTP 200 for every URL, including ones that do not exist — a soft 404, which search engines name as a problem. Here a dead URL answers a real 404.

The client keeps it correct afterwards

Vue has no built-in head management — React 19 hoists title and meta out of a component for free, and Vue does not. Rather than add a head library for one job, the starter does it directly, with the two things a naive version gets wrong.

  • Tags are keyed and reused, not appended. Navigating between ten posts must not leave ten og:title tags in the head.
  • Tags this app created are marked, so cleanup never removes the ones the server injected for the initial route.
src/lib/useHead.js
import { onScopeDispose, watchEffect } from "vue";

const OWNED = "data-head";

function upsert(selector, attributes) {
  let element = document.head.querySelector(selector);

  if (!element) {
    element = document.createElement(attributes.tag);
    element.setAttribute(OWNED, "");
    document.head.appendChild(element);
  }

  for (const [key, value] of Object.entries(attributes)) {
    if (key === "tag") continue;
    if (value == null) element.removeAttribute(key);
    else element.setAttribute(key, String(value));
  }

  return element;
}

/** @param source () => ({ title, meta: [...], link: [...], jsonLd: [...] }) */
export function useHead(source) {
  watchEffect(() => {
    const { title, meta = [], link = [], jsonLd = [] } = source() || {};

    if (title) document.title = title;

    for (const entry of meta) {
      if (!entry) continue;
      const key = entry.name
        ? `meta[name="${entry.name}"]`
        : `meta[property="${entry.property}"]`;
      upsert(key, { tag: "meta", ...entry });
    }

    for (const entry of link) {
      if (!entry) continue;
      upsert(`link[rel="${entry.rel}"]`, { tag: "link", ...entry });
    }

    /* JSON-LD blocks are replaced wholesale: there can be several and they
       have no natural key, so the app's own are cleared and rewritten. */
    for (const script of document.head.querySelectorAll(
      `script[type="application/ld+json"][${OWNED}]`
    )) {
      script.remove();
    }

    for (const block of jsonLd) {
      if (!block) continue;
      const script = document.createElement("script");
      script.type = "application/ld+json";
      script.setAttribute(OWNED, "");
      script.textContent = JSON.stringify(block);
      document.head.appendChild(script);
    }
  });
}

Be clear about what this is not. The metadata is genuinely server-rendered; the article body is still rendered by Vue in the browser. That is the right trade for a SPA and a real improvement over shipping nothing — but if organic search is the main channel for this content, Nuxt renders the same components on the server and removes the compromise.

If the app is behind a login — a dashboard, an internal tool, a help centre for signed-in users — none of this matters. Nobody is indexing it.

9. Publish instantly with a webhook

The tags from step 3 are what this drops.

server/index.js
import { invalidate } from "./cache.js";
import { revalidationSecret } from "./config.js";

app.post("/api/revalidate/all", (req, res) => {
  const body = req.body || {};
  const secret = body.secret || req.get("x-api-key");

  if (secret !== revalidationSecret()) {
    return res.status(401).json({ error: "Invalid token" });
  }

  const tags = ["blogs", "categories"];

  /* When the webhook names a post, its own tag goes too — the listings are
     dropped by the shared tags either way. */
  if (body.post?.slug) tags.push(`blog-${body.post.slug}`);

  const entries = invalidate(tags);

  res.json({ success: true, revalidated: { tags, entries } });
});

Register it

  1. 1In your workspace, open Developer Settings → Webhook Integration
  2. 2Enter https://your-site.com/api/revalidate/all as the URL
  3. 3Generate a secret and save it as REVALIDATION_SECRET in your host's environment
  4. 4Publish a post and confirm the listing updates

Test it locally

Terminal
curl -X POST http://localhost:3000/api/revalidate/all \
  -H "x-api-key: $REVALIDATION_SECRET" \
  -H "content-type: application/json" \
  -d '{"post":{"slug":"hello-world"}}'

10. Deploy

One build, one process, one origin: the server serves the API and the client together, which also means the SPA fallback is already handled.

  1. 1Run npm run build to produce dist/client
  2. 2Deploy the whole project anywhere that runs Node — Railway, Render, Fly.io, a container, a VM
  3. 3Set CMS_API_URL, CMS_API_KEY, REVALIDATION_SECRET, and PUBLIC_SITE_URL on the server
  4. 4Start it with npm start, and add the /api/revalidate/all URL as your publish webhook

The client build must never be given the API key, even under a correctly-named variable. Nothing in src/ should import anything from server/ — that separation is the whole security model here.