はじめに
個人で運営しているパーソナルジムや、フリーランスのパーソナルトレーナーの HP に、お客様の体験談(Voice)と Before/After 写真を載せたい。実装で詰まりやすいのは、次の 3 点です。
-
構造化データ(JSON-LD)の選択肢が多い:
Personで出すのか、LocalBusinessで出すのか、Serviceでも出すのか。Google 検索の星表示候補(Review snippet)に乗せるためにはitemReviewedの紐付けが正しい必要がある - Before/After 写真の同意取消の即時性:お客様から取消依頼があったとき、CDN・ブラウザキャッシュに残った旧 URL が引き続き表示されてしまうと、運用が破綻する
- 景表法の優良誤認表示の機械的検出:体験談本文に「絶対痩せる」「-10kg 保証」「100% 変わる」のような身体的効果の断定が混入しないように、CI で機械的に弾く仕組みがあると安心
本記事では Next.js 14 App Router + Supabase Storage を前提に、次を実装します。
- 体験談データを「本文 + 公開メタ情報 + Before/After 写真の同意レベル」で持つ
-
LocalBusiness(パーソナルジム自体)+Service(提供メニュー)+AggregateRating(3 件以上で出力)+Reviewを、itemReviewedで正しく紐付けて<script type="application/ld+json">に出す - Before/After 写真は Supabase Storage の Signed URL を短い TTL(1 時間)+ DB 側の
withdrawn_atチェックで配信し、同意取消依頼で即時失効させる - Vitest で「同意取消後は Signed URL を発行しない」という不変条件をユニットテスト化する
「自分で組まずに済ませたい」方は、同じ機能を SaaS 化した koe.arere.jp でも提供しています(収集フォーム作成・テスティモニアル管理・埋め込みウィジェット・JSON-LD 自動生成は全プラン無料)。実装のメンタルモデルとしてご一読いただければ。
1. データモデル:撮影同意と公開同意を別カラムに分ける
パーソナルトレーニングの Before/After 写真は、撮影記録のための撮影同意と、HP/SNS 公開のための公開同意が異なるレイヤーです。これを 1 カラムでまとめると、後で「撮影だけ OK・公開は NG」というよくあるパターンが扱えなくなるため、最初から分けて持ちます。
// types/testimonial.ts
export type ConsentLevel = "A" | "B" | "C" | "D";
// A=都道府県・年代・属性のみ / B=イニシャル+年代+属性 / C=氏名+年代+お顔写真 / D=非公開
export type PhotoExposure = "none" | "mosaic" | "neck_down" | "full";
// none=写真非公開 / mosaic=モザイク / neck_down=首から下のみ / full=全身公開
export interface PtTestimonial {
id: string;
body: string; // 本人記入の体験談本文
consent_level: ConsentLevel; // 文面の公開許諾レベル
photo_exposure: PhotoExposure; // 写真の公開可否(文面とは独立)
age_band: "20s" | "30s" | "40s" | "50s" | "60s+";
attribute: string; // "都内勤務" | "在宅ワーク" | "学生" 等
rating?: 1 | 2 | 3 | 4 | 5; // 任意
prefecture?: string; // B/C のみ
initials?: string; // B のみ("K.M." 等)
full_name?: string; // C のみ
photo_storage_path?: string; // Supabase Storage の path("voices/2026/05/abc.jpg")
acquired_at: string; // ISO 8601 取得日
consent_expires_at?: string; // 任意(期限なしなら undefined)
withdrawn_at?: string; // 取消依頼受領日(あれば公開フィードから除外)
}
ポイントは consent_level と photo_exposure を別カラムに分けている点です。同じ取消フォームから同時に変更されることもあれば、片方だけ変更されることもあります(例:写真は引き続き OK だが文面は匿名化レベルを上げたい)。
2. 公開フィードのフィルタ:withdrawn_at と consent_expires_at を一箇所に集める
Review の JSON-LD と画面表示の両方で、取消・期限切れの判定を同じソースから引きたいので、フィルタ関数を 1 つにまとめます。
// lib/testimonials/visible.ts
import type { PtTestimonial } from "@/types/testimonial";
export const isVisible = (t: PtTestimonial, now: Date = new Date()): boolean => {
if (t.consent_level === "D") return false; // 非公開選択
if (t.withdrawn_at) return false; // 取消済
if (t.consent_expires_at && new Date(t.consent_expires_at) < now) return false;
return true;
};
サーバーから取得した直後にこの isVisible() でフィルタしてから、画面と JSON-LD の両方に渡します。now を引数で受けるのは、後の Vitest で時刻を固定してテストするためです。
3. LocalBusiness + Service + AggregateRating + Review の JSON-LD を組む
itemReviewed の紐付け先は LocalBusiness でも Service でも有効ですが、個人パーソナルジムでは LocalBusiness を主体にして AggregateRating をそこに付け、各 Review の itemReviewed も LocalBusiness を指す構成が、Google Search Central の Review snippet の例示と整合しやすいです。
// lib/jsonld/buildPtJsonLd.ts
import type { PtTestimonial, ConsentLevel } from "@/types/testimonial";
import { isVisible } from "@/lib/testimonials/visible";
const authorName = (t: PtTestimonial): string => {
switch (t.consent_level) {
case "C": return t.full_name ?? `${t.age_band} ${t.attribute}`;
case "B": return `${t.initials ?? ""} ${t.age_band} ${t.attribute}`.trim();
case "A":
default: return `${t.age_band} ${t.attribute}`;
}
};
interface BuildArgs {
business: {
name: string; // 屋号(例: "Koe Personal Studio 渋谷")
url: string; // HP の絶対 URL
telephone?: string;
address: {
streetAddress: string;
addressLocality: string; // 例: "渋谷区"
addressRegion: string; // 例: "東京都"
postalCode: string;
addressCountry: "JP";
};
openingHours?: string[]; // 例: ["Mo-Fr 10:00-21:00", "Sa 10:00-18:00"]
};
service: {
name: string; // 例: "パーソナルトレーニング 60 分セッション"
description: string;
areaServed: string; // 例: "東京都渋谷区"
};
testimonials: PtTestimonial[];
}
export const buildPtJsonLd = (args: BuildArgs, now: Date = new Date()) => {
const visible = args.testimonials.filter((t) => isVisible(t, now));
const reviews = visible.map((t) => ({
"@type": "Review",
"@id": `${args.business.url}#review-${t.id}`,
itemReviewed: { "@id": `${args.business.url}#business` },
author: { "@type": "Person", name: authorName(t) },
reviewBody: t.body,
datePublished: t.acquired_at,
...(t.rating ? { reviewRating: { "@type": "Rating", ratingValue: t.rating, bestRating: 5 } } : {}),
}));
// AggregateRating は 3 件以上 + 星評価ありで出す
const rated = visible.filter((t) => typeof t.rating === "number");
const aggregate =
rated.length >= 3
? {
"@type": "AggregateRating",
itemReviewed: { "@id": `${args.business.url}#business` },
ratingValue: (rated.reduce((s, t) => s + (t.rating ?? 0), 0) / rated.length).toFixed(2),
reviewCount: rated.length,
bestRating: 5,
}
: undefined;
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "LocalBusiness",
"@id": `${args.business.url}#business`,
name: args.business.name,
url: args.business.url,
telephone: args.business.telephone,
address: { "@type": "PostalAddress", ...args.business.address },
openingHoursSpecification: args.business.openingHours,
...(aggregate ? { aggregateRating: aggregate } : {}),
},
{
"@type": "Service",
"@id": `${args.business.url}#service`,
name: args.service.name,
description: args.service.description,
areaServed: args.service.areaServed,
provider: { "@id": `${args.business.url}#business` },
},
...reviews,
],
};
};
3 件以上で AggregateRating を出すのは、Google Search Central の Review snippet 仕様と整合性のある運用です(件数が少なすぎると星表示候補から外れやすい)。
4. Server Component で <script type="application/ld+json"> に出す
Next.js 14 App Router の Server Component では、<script> を直接レンダリングできます(クライアント側で評価されないので XSS の論点は JSON.stringify の </script> エスケープのみ)。
// app/(marketing)/page.tsx
import { fetchVisibleTestimonials } from "@/lib/testimonials/fetch";
import { buildPtJsonLd } from "@/lib/jsonld/buildPtJsonLd";
import { VoiceList } from "@/components/VoiceList";
const business = {
name: "Koe Personal Studio 渋谷",
url: "https://example.com",
telephone: "+81-3-XXXX-XXXX",
address: {
streetAddress: "X-X-X",
addressLocality: "渋谷区",
addressRegion: "東京都",
postalCode: "150-0001",
addressCountry: "JP" as const,
},
openingHours: ["Mo-Fr 10:00-21:00", "Sa 10:00-18:00"],
};
const service = {
name: "パーソナルトレーニング 60 分セッション",
description: "個別カウンセリング + 動作評価 + メニュー設計の 60 分セッション。",
areaServed: "東京都渋谷区",
};
export default async function Home() {
const testimonials = await fetchVisibleTestimonials();
const jsonLd = buildPtJsonLd({ business, service, testimonials });
// </script> の混入を防ぐために置換
const safe = JSON.stringify(jsonLd).replace(/</g, "\\u003c");
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: safe }} />
<VoiceList items={testimonials} />
</>
);
}
fetchVisibleTestimonials() は次節で withdrawn_at を SQL レベルで弾く形で実装します。Server Component 経由で出力されるため、JS を切ったブラウザ・Googlebot のいずれにも JSON-LD が届きます。
5. Supabase Storage の Signed URL を「短い TTL + 都度発行」で配信する
Before/After 写真は Public バケットに置くと取消が効きません(公開 URL は CDN にキャッシュされ続ける)。Private バケットに置き、各リクエスト時に TTL 1 時間 + DB の withdrawn_at チェックを経た Signed URL を都度発行します。
// lib/testimonials/fetch.ts
import { createClient } from "@/lib/supabase/server";
import type { PtTestimonial } from "@/types/testimonial";
import { isVisible } from "./visible";
const SIGNED_URL_TTL_SEC = 60 * 60; // 1 時間
interface ResolvedTestimonial extends PtTestimonial {
photo_signed_url?: string;
}
export const fetchVisibleTestimonials = async (): Promise<ResolvedTestimonial[]> => {
const supabase = createClient();
const { data, error } = await supabase
.from("pt_testimonials")
.select("*")
.is("withdrawn_at", null)
.neq("consent_level", "D")
.order("acquired_at", { ascending: false });
if (error) throw error;
const now = new Date();
const visible = (data as PtTestimonial[]).filter((t) => isVisible(t, now));
// Signed URL を都度発行(写真公開可否 photo_exposure !== 'none' の場合のみ)
const resolved = await Promise.all(
visible.map(async (t): Promise<ResolvedTestimonial> => {
if (t.photo_exposure === "none" || !t.photo_storage_path) return t;
const { data: signed, error: e } = await supabase.storage
.from("voices")
.createSignedUrl(t.photo_storage_path, SIGNED_URL_TTL_SEC);
if (e) return t; // 発行失敗時は写真なしで返す
return { ...t, photo_signed_url: signed.signedUrl };
})
);
return resolved;
};
ポイントは 3 つです。
-
DB の
withdrawn_at IS NULLで 1 段目フィルタ(取消済みのレコードは Signed URL すら作らない) -
isVisible()で 2 段目フィルタ(期限切れも弾く。isVisible()を SQL でも書けますが、画面側と同じ関数を使うことで挙動の一致を担保しやすい) - TTL 1 時間 + 都度発行(取消依頼を受けてから最大 1 時間で旧 URL は失効)
「即時に失効したい」場合は TTL を 5 分まで縮められますが、CDN/ブラウザのキャッシュ層を考えると 1 時間程度が現実的なバランスです。完全な即時失効が必要な要件があるなら、Cloudflare Workers や Next.js Route Handler を経由して都度署名する API パスに切り替える設計を選びます。
6. Vitest で「同意取消後は Signed URL を発行しない」を不変条件にする
実装の不変条件を Vitest でユニットテスト化します。Supabase Storage を fake する形で書けば、ネット経由のテストは不要です。
// lib/testimonials/visible.test.ts
import { describe, it, expect } from "vitest";
import { isVisible } from "./visible";
import type { PtTestimonial } from "@/types/testimonial";
const base: PtTestimonial = {
id: "t1",
body: "セッション後、肩の動きがスムーズになりました。",
consent_level: "A",
photo_exposure: "none",
age_band: "30s",
attribute: "都内勤務",
acquired_at: "2026-05-01T00:00:00+09:00",
};
describe("isVisible()", () => {
it("通常レベル A は表示する", () => {
expect(isVisible(base)).toBe(true);
});
it("consent_level=D は隠す(明示非公開)", () => {
expect(isVisible({ ...base, consent_level: "D" })).toBe(false);
});
it("withdrawn_at が立っているレコードは隠す(取消済)", () => {
expect(isVisible({ ...base, withdrawn_at: "2026-05-04T10:00:00+09:00" })).toBe(false);
});
it("consent_expires_at を過ぎたレコードは隠す", () => {
const t = { ...base, consent_expires_at: "2026-05-04T00:00:00+09:00" };
const now = new Date("2026-05-05T00:00:00+09:00");
expect(isVisible(t, now)).toBe(false);
});
});
加えて、buildPtJsonLd() 側にも以下のテストを置きます。
// lib/jsonld/buildPtJsonLd.test.ts
import { describe, it, expect } from "vitest";
import { buildPtJsonLd } from "./buildPtJsonLd";
const business = {
name: "Test Studio",
url: "https://example.com",
address: {
streetAddress: "1-1",
addressLocality: "渋谷区",
addressRegion: "東京都",
postalCode: "150-0001",
addressCountry: "JP" as const,
},
};
const service = { name: "PT 60min", description: "...", areaServed: "東京都" };
describe("buildPtJsonLd()", () => {
it("取消済レコードは Review として出力されない", () => {
const jsonLd = buildPtJsonLd({
business,
service,
testimonials: [
{
id: "t1",
body: "良かった",
consent_level: "A",
photo_exposure: "none",
age_band: "30s",
attribute: "都内勤務",
acquired_at: "2026-05-01T00:00:00+09:00",
withdrawn_at: "2026-05-03T00:00:00+09:00",
},
],
});
const reviews = jsonLd["@graph"].filter((n: { "@type": string }) => n["@type"] === "Review");
expect(reviews).toHaveLength(0);
});
it("AggregateRating は 3 件以上の rating ありレビューでのみ出力される", () => {
const make = (id: string, rating?: 1 | 2 | 3 | 4 | 5) => ({
id,
body: "x",
consent_level: "A" as const,
photo_exposure: "none" as const,
age_band: "30s" as const,
attribute: "都内勤務",
rating,
acquired_at: "2026-05-01T00:00:00+09:00",
});
const twoStarred = buildPtJsonLd({
business,
service,
testimonials: [make("t1", 5), make("t2", 4)],
});
const business1 = twoStarred["@graph"].find((n: { "@type": string }) => n["@type"] === "LocalBusiness");
expect((business1 as { aggregateRating?: unknown }).aggregateRating).toBeUndefined();
const threeStarred = buildPtJsonLd({
business,
service,
testimonials: [make("t1", 5), make("t2", 4), make("t3", 5)],
});
const business2 = threeStarred["@graph"].find((n: { "@type": string }) => n["@type"] === "LocalBusiness");
expect((business2 as { aggregateRating?: unknown }).aggregateRating).toBeDefined();
});
it("itemReviewed が LocalBusiness の @id を指している", () => {
const jsonLd = buildPtJsonLd({
business,
service,
testimonials: [
{
id: "t1",
body: "x",
consent_level: "A",
photo_exposure: "none",
age_band: "30s",
attribute: "都内勤務",
acquired_at: "2026-05-01T00:00:00+09:00",
},
],
});
const review = jsonLd["@graph"].find((n: { "@type": string }) => n["@type"] === "Review");
expect((review as { itemReviewed: { "@id": string } }).itemReviewed["@id"]).toBe(`${business.url}#business`);
});
});
このテストが通っていれば、後から「とりあえず取消済レコードは表示しないつもりだった」のような暗黙の前提が壊れたときに、CI で気付けます。
まとめ
実装の要点を 3 つに絞ると次のとおりです。
-
consent_levelとphoto_exposureを別カラムに分ける:撮影同意と公開同意・文面と写真は別レイヤー。1 つにまとめると後で扱えなくなる -
itemReviewedをLocalBusinessに紐付ける:個人パーソナルジムではPersonよりもLocalBusiness(屋号)に集約し、その上にServiceとAggregateRatingを載せる構成が、Review snippet 仕様と整合的 -
Signed URL は短い TTL + DB 側の
withdrawn_atチェック:取消依頼から最大 1 時間で旧 URL を失効させる。完全即時が要件なら Route Handler 経由の都度署名 API に切り替える
景品表示法の優良誤認表示の論点(「絶対痩せる」「-10kg 保証」等)は、本記事のコードレベルでは扱っていません。本文の機械的な NG 検出は別途、レビュー保存時のサーバーサイドチェック(保存前の flagRiskyBody() のような関数)で弾く形が現実的です(別記事で扱う想定)。
「自分で組まずに済ませたい」方は、同じ機能を SaaS 化した koe.arere.jp でも提供しています(収集フォーム作成・テスティモニアル管理・埋め込みウィジェット・LocalBusiness/Organization JSON-LD 自動生成は全プラン無料)。
参考資料
- schema.org LocalBusiness 型公式リファレンス https://schema.org/LocalBusiness
- schema.org Service 型公式リファレンス https://schema.org/Service
- schema.org AggregateRating 型公式リファレンス https://schema.org/AggregateRating
- schema.org Review 型公式リファレンス https://schema.org/Review
- Google Search Central: Review snippet 構造化データ公式仕様 https://developers.google.com/search/docs/appearance/structured-data/review-snippet
- Next.js 14 App Router: Server Components 公式ドキュメント https://nextjs.org/docs/app/building-your-application/rendering/server-components
- Supabase Storage: createSignedUrl 公式リファレンス https://supabase.com/docs/reference/javascript/storage-from-createsignedurl
- 景品表示法 優良誤認表示・有利誤認表示の基本解説(消費者庁公式) https://www.caa.go.jp/policies/policy/representation/fair_labeling/