対象読者・この記事で分かること
- Expo/React Nativeで個人開発アプリを作っていて、「診断コンテンツだけWebにも出したい」と考えている人
- SPAでOGPが効かない問題を、ビルドツールやサーバーを増やさずに解決したい人
- sharp・canvas系ライブラリで日本語を画像に描画するときの落とし穴を知りたい人
背景
性格診断で自分に合う資格を提案するiOSアプリ「資格みっけ」(Expo/React Native)を、2026年7月17日にリリースしました。
8日後、ダウンロード数は1件でした。自分がインストールした分です。
App Storeに出しただけでは誰にも見つけてもらえない、というのは事前に分かってはいたのですが、実際に数字で見ると打ち手を変える必要があるとはっきりしました。アプリを入れてもらう前に、まず診断そのものを試せる場所が必要だという判断です。
そこで診断部分だけをブラウザで動くWeb版として切り出し、結果ページのOGPがSNSでシェアされることで流入を作る構成にしました。2026年7月26日に公開しています。
この記事は、その実装で詰まった点をまとめたものです。
ロジックとデータをアプリと共有する
アプリ本体の src/data/(質問・タイプ・資格DB)と src/logic/scoring.ts(診断ロジック)、src/theme/tokens.ts(デザイントークン)は、React Native側で最初から次のルールで書いていました。
- 診断ロジックは副作用のない純関数で実装し、Jestで境界値テストを書く
- 色・radius・spacingは
tokens.tsに集約し、ハードコードしない
このルールを決めた時点ではWeb版を作る予定はなく、単に「テストしやすく、UIから独立させたい」という理由でしたが、結果的に react-native や expo-* への依存が一切ない、ただのTypeScriptモジュールになっていました。実際に確認すると、これらのファイルにはReact Native系のimportが1つもありません。
// src/logic/scoring.ts(抜粋)
export function diagnose(
types: AnimalType[],
answers: Answer[],
mode: 'quick' | 'deep',
): DiagnosisResult {
// ここでReact Nativeのモジュールは一切importしていない
// ...
}
そのためWeb版(Vite + TypeScript)からは、パスエイリアスを揃えるだけでそのままimportできました。
// web/src/main.ts:1-3
import { animalTypes } from '@/src/data/types';
import { questions, type QuestionChoice } from '@/src/data/questions';
import { diagnose, type Answer } from '@/src/logic/scoring';
もう一つ気をつけたのは、Web版用のコードをアプリ本体の型チェック範囲に混ぜないことです。ルートの tsconfig.json は web ディレクトリと scripts/**/*.ts を明示的に除外し、web/tsconfig.json という別のtsconfigを用意しました。
// tsconfig.json(ルート)
{
"compilerOptions": { "..." : "..." },
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
"exclude": ["web", "scripts/**/*.ts"]
}
// web/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "Bundler",
"baseUrl": "..",
"paths": { "@/*": ["./*"] }
},
"include": ["src/**/*.ts", "vite.config.ts"]
}
Web版は lib: ["DOM", ...] が必要な一方、アプリ本体はReact Native向けの型定義(expo/tsconfig.base)が前提です。1つのtsconfigに混ぜると片方の型定義がもう片方に漏れるため、参照方向は「Web版からアプリ本体のコードをimportする」だけにして、tsconfigそのものは分離するという方針にしました。
SPAではOGPが効かない問題を、静的ページ生成で解決する
Web版はVite製のSPA(web/src/main.ts)で、質問への回答に応じて #app の中身を書き換えるだけの作りです。ですが、X(Twitter)やLINEなどのクローラはJavaScriptを実行しないため、SPAの index.html にどんなmetaタグを埋め込んでも、動的に決まる「診断結果ごとのOGP」は反映されません。
そこで、ビルド後にタイプの数だけ静的HTMLを生成するスクリプトを用意しました。
// scripts/generate-result-pages.ts(抜粋)
const builtHtml = fs.readFileSync(indexPath, 'utf-8');
const headMatch = builtHtml.match(/<head>([\s\S]*?)<\/head>/);
const bodyMatch = builtHtml.match(/<body([^>]*)>([\s\S]*?)<\/body>/);
for (const type of animalTypes) {
const title = `あなたは【${type.name} / ${type.animal}】|資格みっけ`;
const pageUrl = resultPageUrl(type.id);
const ogImage = `${SITE_BASE_URL}ogp/${type.id}.png`;
const metaTags = [
`<meta property="og:title" content="${escapeHtmlAttr(title)}" />`,
`<meta property="og:image" content="${escapeHtmlAttr(ogImage)}" />`,
`<meta property="og:url" content="${escapeHtmlAttr(pageUrl)}" />`,
// ...
].join('\n ');
const outputDir = path.join(distDir, 'r', type.id);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(path.join(outputDir, 'index.html'), page, 'utf-8');
}
ポイントは、Viteがビルドした index.html の <head> と <body> を土台にして、12タイプ分の dist/r/{typeId}/index.html を複製するという方針です。結果画面のHTML断片自体は web/src/result-view.ts の renderResultMarkup() に切り出してあり、SPA側(クライアントでの描画)と静的生成側(ビルド時のNode実行)の両方から同じ関数を呼んでいます。これにより「結果画面のマークアップを2箇所に書く」事態を避けています。
もう一つ、SPA側の実装で地味に重要なのが、診断完了時の画面遷移を history.pushState などではなく location.href で行っている点です。
// web/src/main.ts:78-80
// SPA内遷移ではなく実ページ遷移にする(静的result htmlのOGPをシェア時に有効にするため)。
// シェア用の絶対URLではなく、現在のオリジン基準の相対パスに遷移する
window.location.href = resultPagePath(import.meta.env.BASE_URL, result.type.id);
History APIでURLだけ書き換えても、ブラウザに読み込まれているのは最初に開いた index.html(=汎用のOGP)のままです。クローラがそのページを再取得したときに正しいOGPを返すには、実際に /r/{typeId}/ へブラウザ遷移して、静的生成された専用HTMLを配信する必要があります。
OGP画像の日本語描画(一番ハマったところ)
最終的に、こういう画像が12タイプ分生成されるようにしました。
シェアされるとこう表示されます。
OGP画像は scripts/generate-ogp.ts で、タイプごとに1200×630のPNGを生成しています。キャラクター画像とテキストをsharpで合成する構成で、テキスト部分はSVGとして組み立てています。
フォントを明示しないと日本語が静かに壊れる
最初、SVGに font-family を指定せずにsharpでレンダリングしたところ、環境によっては日本語が豆腐(□)になったり、一部の文字だけ欠落したりしました。厄介なのは、これがエラーにならず「気づかないと壊れたOGP画像がそのまま公開される」タイプの不具合だという点です。sharpはlibvipsの上でPangoやfontconfigを使ってSVGのテキストを描画しますが、実行環境にフォントが入っている保証がなく、フォールバック時の挙動も環境依存だからです。
対策として、アプリ側で採用しているM PLUS Rounded 1c(@expo-google-fonts/m-plus-rounded-1c)のTTFファイルをnode_modulesから読み、@napi-rs/canvas の GlobalFonts に登録した上で、SVGにも @font-face としてbase64埋め込みしています。
// scripts/generate-ogp.ts:40-53
const FONT_EXTRA_BOLD_PATH = path.join(
repoRoot,
'node_modules/@expo-google-fonts/m-plus-rounded-1c/800ExtraBold/MPLUSRounded1c_800ExtraBold.ttf',
);
const FONT_EXTRA_BOLD_FAMILY = 'OgpExtraBold';
GlobalFonts.registerFromPath(FONT_EXTRA_BOLD_PATH, FONT_EXTRA_BOLD_FAMILY);
// scripts/generate-ogp.ts:256-265(buildSvg内、抜粋)
<defs>
<style>
@font-face {
font-family: '${FONT_EXTRA_BOLD_FAMILY}';
src: url(data:font/ttf;base64,${fs.readFileSync(FONT_EXTRA_BOLD_PATH).toString('base64')}) format('truetype');
}
</style>
</defs>
GlobalFonts への登録は @napi-rs/canvas でテキスト幅を測るため、SVG内の @font-face はsharp(libvips)がレンダリングする際にも同じフォントを使わせるためのもので、両方が必要でした。
長いタイプ名を1行に収めるための実測ベースの自動縮小
タイプ名は12種類で文字数が揃っていないため、固定フォントサイズだと長い名前だけ折り返されたりはみ出したりします。そこで @napi-rs/canvas の measureText() で実際の描画幅を測り、収まるまでフォントサイズを段階的に縮小しています。
// scripts/generate-ogp.ts:58-93
function measureWidth(text: string, fontSize: number, family: string): number {
measureCtx.font = `${fontSize}px "${family}"`;
return measureCtx.measureText(text).width;
}
function fitSingleLineFontSize(
text: string,
maxWidth: number,
family: string,
start: number,
step: number,
min: number,
): number {
let size = start;
while (size > min) {
if (measureWidth(text, size, family) <= maxWidth) return size;
size -= step;
}
return min;
}
行間を係数で決めると重なる
複数行のテキスト(ラベル、タイプ名、キャッチコピーなど)を並べる際、最初は「フォントサイズ × 1.4」のような係数で行間を決めていましたが、フォントサイズが自動縮小されるとブロック同士の間隔が想定より詰まって重なることがありました。原因は、係数方式だとフォント自体の実際の高さ(ascent/descent)を無視しているためです。
そこで measureText() が返す fontBoundingBoxAscent / fontBoundingBoxDescent を使い、前の行のディセントと次の行のアセントを積み上げてベースラインを決める方式に変更しました。
// scripts/generate-ogp.ts:63-67, 195-208
function fontMetrics(fontSize: number, family: string): { ascent: number; descent: number } {
measureCtx.font = `${fontSize}px "${family}"`;
const m = measureCtx.measureText('あ');
return { ascent: m.fontBoundingBoxAscent, descent: m.fontBoundingBoxDescent };
}
// 前の行のディセント・行間・次の行のアセントを積み上げてベースラインを決める。
// フォントサイズが自動縮小されても間隔が詰まらないよう、実測メトリクス基準で計算する
function stackBaselines(
startTop: number,
lines: { fontSize: number; family: string; gapAfter: number }[],
): number[] {
const baselines: number[] = [];
let top = startTop;
for (const line of lines) {
const { ascent, descent } = fontMetrics(line.fontSize, line.family);
const baseline = top + ascent;
baselines.push(baseline);
top = baseline + descent + line.gapAfter;
}
return baselines;
}
日本語の改行位置は単語分割だけでは不自然になることがある
キャッチコピーが1行に収まらない場合の改行位置は、tiny-segmenter で分かち書き(単語分割)を行い、トークン境界のみを改行候補にすることで単語の途中では折り返さないようにしています。読点・句点での分割を優先し、なければ助詞(「は」「が」「を」など)、それもなければ全体が均等になる境界を選ぶ、という優先順位です。
// scripts/generate-ogp.ts:95-122(抜粋)
const PUNCTUATION_TOKENS = ['、', '。'];
// 「と」は「とことん」「ちょっと」等の語中に単独トークンとして誤分割されやすいため優先対象から除外する
const PARTICLE_TOKENS = ['は', 'が', 'を', 'に', 'で', 'も', 'の'];
function tokenBoundaries(text: string): { pos: number; token: string }[] {
const tokens = segmenter.segment(text);
// ...
}
ただ、これでも一部のキャッチコピーは機械的な分割だと不自然な位置で折り返されました。全パターンに対応する汎用ロジックを追うよりも、12タイプ分しかないデータなので手動で上書きするマップを用意する方が早いと判断しました。
// scripts/generate-ogp.ts:143-150
// 自動アルゴリズムでは不自然になる箇所を手動で上書きする(typeIdをキーに1行目/2行目を指定)
// ここに無いtypeIdは自動の折り返しロジックを使う
const CATCHPHRASE_LINE_OVERRIDES: Record<string, [string, string]> = {
hawk: ['高みを目指して', '一気に駆け上がるチャレンジャー'],
owl: ['新しい知識をひとりで', 'どんどん吸収する探究者'],
panda: ['好きなものをとことん集める', 'マイペース収集家'],
elephant: ['そばにいるだけで', '安心感を与える縁の下の力持ち'],
};
データ量が少ない個人開発だからこそ選べる割り切り方だと思います。
privateリポジトリのままGitHub Pages無料枠で公開する
アプリ本体のリポジトリはprivateにしているため、そのままではGitHub Pagesで配信できません。GitHub Pagesをprivateリポジトリで使うにはGitHub Proプラン等が必要になるためです。そこで、ビルド出力だけを別のpublicなユーザーサイトリポジトリ(moriosan1986-design.github.io)にコピーする構成にしました。
// web/scripts/deploy.mjs(抜粋)
const publicSitePath = path.resolve(
repoRoot,
process.env.PUBLIC_SITE_PATH || '../moriosan1986-design.github.io',
);
const targetDir = path.join(publicSitePath, 'shikaku-mikke', 'quiz');
if (!fs.existsSync(publicSitePath)) {
console.error(
`公開リポジトリが見つかりません: ${publicSitePath}\n` +
`moriosan1986-design.github.io をクローンしてから再実行してください` +
`(別の場所にクローンした場合は環境変数 PUBLIC_SITE_PATH でパスを指定してください)。`,
);
process.exit(1);
}
if (isDryRun) {
// 削除・コピーは行わず、対象ファイルの一覧だけ表示する
process.exit(0);
}
fs.rmSync(targetDir, { recursive: true, force: true });
fs.cpSync(distDir, targetDir, { recursive: true });
設計上気をつけた点は次のとおりです。
- コピー先パスを環境変数
PUBLIC_SITE_PATHで差し替え可能にし、決め打ちにしない -
--dry-runで実際に書き込む前にコピー対象ファイル一覧を確認できるようにする - コピー先が存在しない場合は黙って何もするのではなく、エラーで停止する(公開リポジトリを手元にクローンし忘れたまま実行して空振りする事故を防ぐため)
- コピー前に対象ディレクトリを削除してから丸ごとコピーすることで、リネーム・削除されたファイルが残留しないようにする
- パス結合はすべて
path.join/path.resolveを使い、Windows環境でも動くようにする
もう一つ落とし穴だったのが、shikaku-mikke/quiz/ というサブディレクトリ配信にするため、Viteの base オプション設定が必須だったことです。設定しないと、ビルドされたJS/CSSやアセットのURLがルート相対(/assets/...)で出力され、サブディレクトリ配信では404になります。
// web/vite.config.ts
import { SITE_BASE_PATH } from './src/site-config';
export default defineConfig({
base: SITE_BASE_PATH, // '/shikaku-mikke/quiz/'
build: { outDir: 'dist', emptyOutDir: true },
});
また、公開先はGitHub Pagesのデフォルト設定(Jekyll経由の配信)なので、アンダースコアで始まるディレクトリ・ファイル(_next のような出力)はJekyllに無視されてしまう制約があります。今回はViteの出力ディレクトリ名がデフォルトの assets/ なので該当しませんでしたが、ビルドツールの設定次第では踏み抜く可能性があるため、サブディレクトリ配信を検討する人は一度ビルド出力のファイル名を確認しておくとよいと思います。
絶対URLと相対パスを混同したバグ
OGPやXシェア用のURLは絶対URL(https://moriosan1986-design.github.io/shikaku-mikke/quiz/r/xxx/)でなければ機能しませんが、アプリ内の画面遷移は本来相対パスで組むべきものです。実装当初、この2つを同じ関数で組んでいたため、ローカルでのプレビュー(vite preview や自前のHTTPサーバー)から診断を進めると、結果画面遷移時に本番ドメインへ飛んで404になるという不具合が起きました。
修正後は用途別に関数を分けています。
// web/src/site-config.ts
export const SITE_BASE_PATH = '/shikaku-mikke/quiz/';
export const SITE_BASE_URL = `https://moriosan1986-design.github.io${SITE_BASE_PATH}`;
// シェア・OGP用の絶対URL(本番ドメイン基準)
export function resultPageUrl(typeId: string): string {
return `${SITE_BASE_URL}r/${typeId}/`;
}
// アプリ内遷移用の相対パス。basePathは呼び出し側でimport.meta.env.BASE_URL等、実行時のベースパスを渡す
export function resultPagePath(basePath: string, typeId: string): string {
return `${basePath}r/${typeId}/`;
}
呼び出し側では、画面遷移には import.meta.env.BASE_URL(Viteが実行時のベースパスを渡してくれる変数)を使い、シェアには絶対URLを使う、と明確に分けました。
// web/src/main.ts:80
window.location.href = resultPagePath(import.meta.env.BASE_URL, result.type.id);
「絶対URLを組む関数」と「相対パスを組む関数」を型レベルでも名前レベルでも分けたことで、以降は同じ間違いが起きにくくなったと思います。
アフィリエイトリンクの媒体分離
アフィリエイトのASP(A8.net)は、同じ資格の広告でもサイト用とアプリ用で別のリンクを発行する仕組みになっています。既存のアプリ側は url フィールドのみを持っていましたが、Web版のために webUrl を追加しました。
// src/data/licenses.ts
export interface LicenseAffiliate {
asp: string;
program: string;
cta: string;
url: string;
webUrl?: string;
checkedAt: string;
}
Web版のCTAボタンは webUrl が設定されている場合のみ表示し、url(アプリ用)へのフォールバックは実装していません。
// web/src/license-card.ts:25-27
const cta = license.affiliate?.webUrl
? `<a class="cta cta--${variant}" href="${escapeHtml(license.affiliate.webUrl)}" target="_blank" rel="noopener sponsored">${escapeHtml(license.affiliate.cta)}</a>`
: `<details class="license-more"><summary>詳しく見る</summary><p>${escapeHtml(license.description)}</p>...</details>`;
webUrl が未設定のときは広告リンクの代わりに「詳しく見る」の折りたたみ説明を出す作りで、アプリ用リンクを誤って踏ませることはありません。媒体をまたぐアフィリエイトは、フォールバックを実装しないこと自体が事故防止になると考えています。
おまけ: Windowsでのformat:check誤検知
開発をWindows + PowerShellで行っているのですが、Gitの core.autocrlf=true 設定により、作業ツリー上のファイルはCRLF、リポジトリに格納されるblobはLFという状態になります。この結果、git diff 上は差分がないのに、Prettierの --check が「改行コードが違う」と判定して失敗する現象が起きました。
.gitattributes に1行追加することで解決しています。
* text=auto eol=lf
これでチェックアウト時・コミット時の改行コードがLFに統一され、format:check の誤検知がなくなりました。
まだできていないこと
- Web Share API非対応環境(あるいは呼び出しに失敗した場合)のフォールバックは、現状Xのシェア画面を開くだけの簡易的な実装にとどまっています
- アフィリエイトリンク(
webUrl)は一部の資格にしか設定できておらず、大半は「詳しく見る」の説明表示のままです - アクセス解析はまだ導入していません。公開直後のため、実際に流入があったかどうかは確認できていません
おわりに
作ってみて一番効いたと感じているのは、OGP画像をタイプごとに用意したことです。診断コンテンツは「他人の結果を見る」ところから連鎖が始まるので、シェアされたときに画像で結果が伝わるかどうかが分かれ目になります。テキストとリンクだけでは、そもそも目に留まりません。
一方で、ここまで作っても知られていなければ結果は変わらないので、公開できたのはスタート地点に立っただけだとも思っています。
診断は1分・10問で、インストール不要で試せます。

