Vue 與 Nuxt 中的 OG 圖片
使用 FastOG API 在 Vue 和 Nuxt 中生成 OG 圖片 — 需訂閱 Dynamic API(請在儀表板中建立您的 API 金鑰),以訂閱為基礎,並透過 HMAC 確保安全。本指南將示範如何建立一個簽署工具(signer utility),用於產生 HMAC 簽署的 OG 圖片 URL,並即時渲染動態的 Open Graph 圖片。上線前,請先使用免費的 OG 圖片測試工具(Free OG Image Tester)進行預覽。
快速開始
1. 建立簽署者工具程式
javascript
// lib/og-image-signer.js
import crypto from "crypto";
export function generateSignedOgUrl(params, options = {}) {
const {
baseUrl = "https://fastog.com/api/v1/og",
apiKey = process.env.FASTOG_API_KEY,
hmacSecret = process.env.FASTOG_HMAC_SECRET,
} = options;
if (!apiKey || !hmacSecret) {
throw new Error("Missing FASTOG_API_KEY or FASTOG_HMAC_SECRET");
}
// RFC-3986 percent-encoding: keep A-Za-z0-9-._~
const enc = (s) =>
encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
// Every query param except the signature, INCLUDING key
const all = { ...params, key: apiKey };
// Canonical query: sorted enc(key)=enc(value) pairs joined with &
const pairs = [];
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
pairs.push(`${enc(k)}=${enc(vv)}`);
}
}
pairs.sort();
const canonical = pairs.join("&");
// Sign the exact bytes the server will verify
const signature = crypto
.createHmac("sha256", hmacSecret)
.update(`GET/api/v1/og\n${canonical}`)
.digest("hex");
// Build the URL from the same pairs so bytes match the canonical form
const url = new URL(baseUrl);
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
url.searchParams.append(Array.isArray(v) ? `${k}[]` : k, vv);
}
}
url.searchParams.set("s", signature);
return url.toString();
}
2. 環境變數
bash
# .env
FASTOG_API_KEY=sk_your_full_api_key_here
FASTOG_HMAC_SECRET=your_64_char_hex_secret_here
Nuxt 3
使用 useHead 的頁面
vue
<!-- pages/blog/[slug].vue -->
<script setup>
const route = useRoute();
const { data: post } = await useAsyncData("post", () =>
getPost(route.params.slug),
);
const { data: ogImageUrl } = await useAsyncData("og-image", () =>
$fetch("/api/og", {
query: {
title: post.value.title,
template: "blog",
subtitle: post.value.excerpt,
},
}).then((res) => res.ogImageUrl),
);
useHead({
title: post.value.title,
meta: [
{ property: "og:image", content: ogImageUrl.value },
{ property: "og:title", content: post.value.title },
{ property: "og:description", content: post.value.excerpt },
],
});
</script>
<template>
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.excerpt }}</p>
</article>
</template>
伺服器 API 路由
javascript
// server/api/og.get.js
import { generateSignedOgUrl } from "~/lib/og-image-signer";
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { title, template = "default", ...params } = query;
if (!title) {
throw createError({ statusCode: 400, message: "Missing title" });
}
const ogImageUrl = generateSignedOgUrl({
template,
title,
...params,
});
return { ogImageUrl };
});
Vue 3 (SPA)
Composable(組合式函式)
javascript
// composables/useOgImage.js
import { ref, watch } from "vue";
export function useOgImage(params) {
const ogImageUrl = ref(null);
watch(
() => params,
async (newParams) => {
try {
const searchParams = new URLSearchParams(newParams);
const res = await fetch(`/api/og?${searchParams}`);
const data = await res.json();
ogImageUrl.value = data.ogImageUrl;
} catch (err) {
console.error("Failed to generate OG image:", err);
}
},
{ immediate: true },
);
return ogImageUrl;
}
Component(元件)
vue
<!-- components/BlogPost.vue -->
<script setup>
import { useOgImage } from "~/composables/useOgImage";
const props = defineProps({
post: Object,
});
const ogImageUrl = useOgImage({
title: props.post.title,
template: "blog",
subtitle: props.post.excerpt,
});
</script>
<template>
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.excerpt }}</p>
</article>
</template>
Backend API(後端 API,Express)
javascript
// server/index.js
import express from "express";
import crypto from "crypto";
const app = express();
app.get("/api/og", (req, res) => {
const { title, template = "default", ...params } = req.query;
if (!title) {
return res.status(400).json({ error: "Missing title" });
}
const apiKey = process.env.FASTOG_API_KEY;
const hmacSecret = process.env.FASTOG_HMAC_SECRET;
const enc = (s) =>
encodeURIComponent(s).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
const all = { template, title, ...params, key: apiKey };
const pairs = [];
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
pairs.push(`${enc(k)}=${enc(vv)}`);
}
}
pairs.sort();
const canonical = pairs.join("&");
const signature = crypto
.createHmac("sha256", hmacSecret)
.update(`GET/api/v1/og\n${canonical}`)
.digest("hex");
const url = new URL("https://fastog.com/api/v1/og");
for (const [k, v] of Object.entries(all)) {
for (const vv of Array.isArray(v) ? v : [v]) {
url.searchParams.append(Array.isArray(v) ? `${k}[]` : k, vv);
}
}
url.searchParams.set("s", signature);
res.json({ ogImageUrl: url.toString() });
});
app.listen(3001, () => {
console.log("API server running on port 3001");
});
電子商務範例
vue
<!-- pages/products/[id].vue -->
<script setup>
const route = useRoute();
const { data: product } = await useAsyncData("product", () =>
getProduct(route.params.id),
);
const { data: ogImageUrl } = await useAsyncData("og-image", () =>
$fetch("/api/og", {
query: {
title: product.value.name,
template: "ecommerce",
price: product.value.price,
image: product.value.image,
},
}).then((res) => res.ogImageUrl),
);
useHead({
meta: [
{ property: "og:image", content: ogImageUrl.value },
{ property: "og:title", content: product.value.name },
],
});
</script>
<template>
<main>
<h1>{{ product.name }}</h1>
<p>{{ product.price }}</p>
</main>
</template>
使用者個人資料(部落格範本)
FastOG 沒有 profile 範本 — 請使用 blog 來建立個人資料風格的卡片,將使用者名稱傳入 title、簡介傳入 subtitle,並將名稱傳入 author。
vue
<!-- pages/users/[username].vue -->
<script setup>
const route = useRoute();
const { data: user } = await useAsyncData("user", () =>
getUser(route.params.username),
);
const { data: ogImageUrl } = await useAsyncData("og-image", () =>
$fetch("/api/og", {
query: {
title: `${user.value.name} - Profile`,
template: "blog",
subtitle: user.value.bio,
author: user.value.name,
},
}).then((res) => res.ogImageUrl),
);
useHead({
meta: [
{ property: "og:image", content: ogImageUrl.value },
{ property: "og:title", content: `${user.value.name} - Profile` },
],
});
</script>
常見陷阱
用戶端簽章
切勿在瀏覽器程式碼中簽署 OG URL。
❌ 錯誤:
vue
<script setup>
import { generateSignedOgUrl } from "~/lib/og-image-signer";
// Runs in browser — secret exposed!
const ogUrl = generateSignedOgUrl({ title: post.title });
</script>
✅ 正確:
javascript
// server/api/og.get.js (runs on server only)
import { generateSignedOgUrl } from "~/lib/og-image-signer";
export default defineEventHandler((event) => {
return { ogImageUrl: generateSignedOgUrl({ title: query.title }) };
});
簽署的位元組與傳送的內容不同
簽章涵蓋 GET/api/v1/og 加上一個換行符號再加上正規化的查詢字串——即所有參數(s 除外,包含 key)經排序後的 enc(key)=enc(value) 鍵值對。請使用相同的編碼鍵值對來建構最終 URL,否則伺服器會回傳 401 invalid_signature。