주제

Vue 및 Nuxt에서의 OG 이미지

FastOG API를 사용하여 Vue와 Nuxt에서 OG 이미지를 생성합니다 — Dynamic API 구독(대시보드에서 API 키 생성)을 통해, 구독 기반이며 HMAC으로 보안이 적용됩니다. 이 가이드는 HMAC 서명된 OG 이미지 URL을 생성하는 서명 유틸리티(signer utility)를 만들고 동적 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>

백엔드 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을 제외한 모든 매개변수의 정렬된 enc(key)=enc(value) 쌍이며, key도 포함합니다. 동일한 인코딩된 쌍으로 최종 URL을 구성해야 합니다. 그렇지 않으면 서버가 401 invalid_signature을 반환합니다.

전체 API 참조

무료로 체험해 보세요 — 가입 불필요

몇 초 만에 동적 OG 이미지를 미리 보고 테스트하세요.

무료 OG 이미지 테스터 열기

A BeCaSimulations site