「Astro でブログを運営しているのですが、Next.js のチュートリアルを読んでも app/api 前提で参考にしづらい」— こうした相談を受けることが、最近とても増えてきました。Astro は静的サイト寄りに設計されているぶん、AI機能を組み込もうとすると Next.js 系の記事がそのままでは使えず、つまずいてしまう方が多いようです。
私自身もコンテンツサイトを Astro で運用していますが、Gemini API を組み合わせると、軽量で高速というAstroの長所を保ったまま、AI要約・関連記事提案・自動タグ付けといった機能を素直に追加できます。ここではその実装手順を、動くコードと一緒に整理してお伝えします。
なぜ Astro × Gemini API が相性良いのか
Astro は基本「静的サイトジェネレータ」として設計されていますが、output: 'server' または output: 'hybrid' モードに切り替えると、ページ単位でサーバー実行を有効化できます。AI機能のように「ユーザーリクエストごとに動的に生成したい」処理は、この仕組みを使って必要なページにだけサーバー機能を割り当てれば、サイト全体の高速性を犠牲にしません。
私が好きなのは、Astro の Content Collections(src/content/)が Markdown/MDX の記事メタデータを型安全に扱える点です。Gemini API で生成したサマリーや関連タグをここに流し込めば、記事一覧やカテゴリページがほぼ既存ロジックのまま動きます。
参考: Next.js 15 App Router × Gemini API フルスタック本番構築 では Next.js での同様パターンを扱っています。アプローチの違いを比較すると理解が深まります。
環境セットアップ:Astro 5 + Hybrid モード
まずは新規プロジェクトの最小構成です。Astro 5 系を前提にしています。
# プロジェクト作成
npm create astro@latest my-ai-blog -- --template minimal --typescript strict
cd my-ai-blog
# 公式 Gemini SDK と Node アダプターを追加
npm install @google/genai
npx astro add node
astro.config.mjs を以下のように設定します。output: 'hybrid' を選ぶと、デフォルトは静的、必要なページだけサーバー実行になります。
// astro.config.mjs
import { defineConfig } from 'astro/config' ;
import node from '@astrojs/node' ;
export default defineConfig ({
output: 'hybrid' , // 静的+必要箇所のみSSR
adapter: node ({ mode: 'standalone' }) ,
server: { port: 4321 } ,
}) ;
APIキーは .env で管理します。Astro は import.meta.env で読み込めますが、サーバー専用の値は PUBLIC_ を付けない点が肝心です。
# .env
GEMINI_API_KEY = YOUR_GEMINI_API_KEY
Server Endpoint で Gemini API を呼び出す
Astro では src/pages/api/ にファイルを置けば、それがそのまま REST エンドポイントになります。Next.js の Route Handlers と似た構成ですが、こちらは APIRoute 型を使います。
以下は、記事の本文を受け取って要約を返す最小実装です。要約生成は記事一覧ページや SNS シェア時に使うことを想定しています。
// src/pages/api/summarize.ts
import type { APIRoute } from 'astro' ;
import { GoogleGenAI } from '@google/genai' ;
const ai = new GoogleGenAI ({ apiKey: import . meta .env. GEMINI_API_KEY });
export const prerender = false ; // SSR を有効化
export const POST : APIRoute = async ({ request }) => {
try {
const { content } = await request. json ();
if ( ! content || typeof content !== 'string' ) {
return new Response ( JSON . stringify ({ error: 'content is required' }), { status: 400 });
}
const result = await ai.models. generateContent ({
model: 'gemini-2.5-flash' ,
contents: [
{
role: 'user' ,
parts: [{
text: `次の記事を日本語で140文字以内に要約してください。装飾的な前置きは禁止し、要点だけを書いてください。 \n\n ${ content }` ,
}],
},
],
config: { temperature: 0.3 , maxOutputTokens: 256 },
});
const summary = result.text?. trim () ?? '' ;
return new Response ( JSON . stringify ({ summary }), {
status: 200 ,
headers: { 'Content-Type' : 'application/json' },
});
} catch (err) {
console. error ( '[summarize] error:' , err);
return new Response ( JSON . stringify ({ error: 'Generation failed' }), { status: 500 });
}
};
prerender = false を必ず指定してください。これがないとビルド時に静的化されてしまい、リクエストごとに動的生成ができなくなります。output: 'hybrid' モードでサーバー実行が必要なファイルだけ明示的に切り替える、というのが Astro の設計思想です。
期待される動作はシンプルです。POST /api/summarize に {"content": "..."} を送ると、{"summary": "..."} が返ります。
curl -X POST http://localhost:4321/api/summarize \
-H "Content-Type: application/json" \
-d '{"content": "Astro は静的サイトジェネレータの一種で..."}'
# → {"summary": "Astro は静的サイトジェネレータで..."}
Content Collections と組み合わせる
Astro の真価は Content Collections との統合にあります。記事メタデータをスキーマ定義しておくと、Gemini API で生成した要約や自動タグもそのまま型安全に扱えます。
// src/content/config.ts
import { defineCollection, z } from 'astro:content' ;
const blog = defineCollection ({
type: 'content' ,
schema: z. object ({
title: z. string (),
pubDate: z.coerce. date (),
description: z. string (). optional (),
tags: z. array (z. string ()). default ([]),
aiSummary: z. string (). optional (), // Gemini で生成
aiTags: z. array (z. string ()). default ([]),
}),
});
export const collections = { blog };
ビルド時にすべての記事を一括処理したい場合は、astro build の前にスクリプトで生成しておく方法が現実的です。これだと毎回のページアクセスでAPIを叩かずに済み、コストもレイテンシも抑えられます。
// scripts/generate-summaries.ts
import { readdir, readFile, writeFile } from 'node:fs/promises' ;
import { join } from 'node:path' ;
import matter from 'gray-matter' ;
import { GoogleGenAI } from '@google/genai' ;
const ai = new GoogleGenAI ({ apiKey: process.env. GEMINI_API_KEY ! });
const BLOG_DIR = join (process. cwd (), 'src/content/blog' );
async function summarize ( text : string ) : Promise < string > {
const result = await ai.models. generateContent ({
model: 'gemini-2.5-flash' ,
contents: [{ role: 'user' , parts: [{ text: `140文字以内で日本語要約: ${ text }` }] }],
config: { temperature: 0.3 , maxOutputTokens: 200 },
});
return result.text?. trim () ?? '' ;
}
const files = await readdir ( BLOG_DIR );
for ( const file of files. filter ( f => f. endsWith ( '.md' ))) {
const path = join ( BLOG_DIR , file);
const raw = await readFile (path, 'utf-8' );
const { data , content } = matter (raw);
if (data.aiSummary) {
console. log ( `SKIP: ${ file }(既にaiSummaryあり)` );
continue ;
}
const summary = await summarize (content. slice ( 0 , 4000 ));
data.aiSummary = summary;
await writeFile (path, matter. stringify (content, data), 'utf-8' );
console. log ( `OK: ${ file } → ${ summary . slice ( 0 , 40 ) }...` );
}
package.json に組み込みます。
{
"scripts" : {
"prebuild" : "tsx scripts/generate-summaries.ts" ,
"build" : "astro build"
}
}
これで npm run build 時に未処理の記事だけを自動要約してから本番ビルドが走ります。AI生成コストを記事数分しか使わない設計です。
関連記事を Gemini Embedding でレコメンド
要約だけでなく「関連記事の自動表示」も、Astro で素直に実装できます。Gemini の埋め込みモデル gemini-embedding-001 で各記事をベクトル化し、コサイン類似度で関連性を計算するのが定番のアプローチです。
// scripts/build-embeddings.ts
import { readdir, readFile, writeFile } from 'node:fs/promises' ;
import { join } from 'node:path' ;
import matter from 'gray-matter' ;
import { GoogleGenAI } from '@google/genai' ;
const ai = new GoogleGenAI ({ apiKey: process.env. GEMINI_API_KEY ! });
async function embed ( text : string ) : Promise < number []> {
const result = await ai.models. embedContent ({
model: 'gemini-embedding-001' ,
contents: [{ parts: [{ text: text. slice ( 0 , 8000 ) }] }],
config: { taskType: 'SEMANTIC_SIMILARITY' },
});
return result.embeddings?.[ 0 ]?.values ?? [];
}
const BLOG_DIR = join (process. cwd (), 'src/content/blog' );
const embeddings : Record < string , number []> = {};
for ( const file of ( await readdir ( BLOG_DIR )). filter ( f => f. endsWith ( '.md' ))) {
const raw = await readFile ( join ( BLOG_DIR , file), 'utf-8' );
const { content } = matter (raw);
embeddings[file. replace ( '.md' , '' )] = await embed (content);
console. log ( `embed: ${ file }` );
}
await writeFile (
join (process. cwd (), 'src/data/embeddings.json' ),
JSON . stringify (embeddings, null , 2 ),
'utf-8' ,
);
この embeddings.json を Astro コンポーネントから読み込み、ページ表示時にコサイン類似度で関連記事を計算します。
---
// src/components/RelatedArticles.astro
import { getCollection } from 'astro:content' ;
import embeddings from '../data/embeddings.json' ;
interface Props { currentSlug : string }
const { currentSlug } = Astro.props;
function cosine ( a : number [], b : number []) : number {
let dot = 0 , na = 0 , nb = 0 ;
for ( let i = 0 ; i < a. length ; i ++ ) {
dot += a[i] * b[i];
na += a[i] ** 2 ;
nb += b[i] ** 2 ;
}
return dot / (Math. sqrt (na) * Math. sqrt (nb));
}
const current = embeddings[currentSlug] as number [] | undefined ;
const allBlog = await getCollection ( 'blog' );
const related = current
? Object. entries (embeddings)
. filter (([ slug ]) => slug !== currentSlug)
. map (([ slug , vec ]) => ({
slug,
score: cosine (current, vec as number []),
entry: allBlog. find ( e => e.slug === slug),
}))
. sort (( a , b ) => b.score - a.score)
. slice ( 0 , 3 )
: [];
---
< aside class = "related" >
< h2 >関連記事</ h2 >
< ul >
{ related. map ( r => r.entry && (
< li >< a href = { `/blog/${ r . entry . slug }` } > { r.entry.data.title } </ a ></ li >
)) }
</ ul >
</ aside >
ビルド時に1回計算するため、本番ページでは API コール不要・即座に表示されます。記事数が数千を超える場合は SQLite にベクトルを保存して近似最近傍検索(ANN)に切り替える設計が必要ですが、個人ブログ規模ならこのインメモリ方式で十分実用的です。
埋め込みモデルの詳細は Matryoshka でベクトル次元を削減し DB コストを下げる設計 も参考になります。
約束した「自動タグ付け」を構造化出力で安定させる
要約と関連記事まで来たところで、冒頭で挙げた三本柱の最後 ― 自動タグ付け ― を実装します。タグ付けは「自由に書かせる」と表記揺れが必ず起きます。AI、ai、人工知能 が混在し、タグページが分裂してしまうのです。個人開発でコンテンツサイトを回していると、この表記揺れの掃除に地味な時間を取られます。
これを防ぐ要は2つです。出力を JSON スキーマで縛ること、そして「既存タグ辞書からの選択」を強制することです。Gemini の responseMimeType: 'application/json' と responseSchema を使うと、配列の各要素の型まで固定できます。
// scripts/auto-tag.ts
import { GoogleGenAI, Type } from '@google/genai' ;
const ai = new GoogleGenAI ({ apiKey: process.env. GEMINI_API_KEY ! });
// サイトで使う正規のタグ辞書(表記を1つに固定する核)
const TAG_VOCABULARY = [
'gemini-api' , 'astro' , 'rag' , 'embedding' ,
'structured-output' , 'ssr' , 'performance' , 'cost-optimization' ,
];
export async function suggestTags ( title : string , body : string ) : Promise < string []> {
const result = await ai.models. generateContent ({
model: 'gemini-2.5-flash' ,
contents: [{
role: 'user' ,
parts: [{
text:
`次の記事に付けるタグを最大4つ選んでください。` +
`必ず以下の語彙からのみ選び、新しい語は作らないでください。` +
`語彙: ${ TAG_VOCABULARY . join ( ', ' ) } / タイトル: ${ title } / 本文: ${ body . slice ( 0 , 4000 ) }` ,
}],
}],
config: {
temperature: 0 , // タグ付けは決定的に
responseMimeType: 'application/json' ,
responseSchema: {
type: Type. OBJECT ,
properties: {
tags: {
type: Type. ARRAY ,
items: { type: Type. STRING , enum: TAG_VOCABULARY },
maxItems: 4 ,
},
},
required: [ 'tags' ],
},
},
});
const parsed = JSON . parse (result.text ?? '{"tags":[]}' ) as { tags : string [] };
// enum 外が万一混ざっても辞書で最終フィルタ(二重の保険)
return parsed.tags. filter ( t => TAG_VOCABULARY . includes (t));
}
肝は2つあります。temperature: 0 でタグ付けを決定的にすること、そして items.enum に辞書を渡してモデルに新語を作らせない ことです。私はこの「語彙を閉じる」設計に切り替えてから、表記揺れ起因のタグページ重複がほぼ出なくなりました。enum を使っても確率はゼロにはならないので、最後に TAG_VOCABULARY.includes でもう一度濾しておくと安心です。
構造化出力そのものの設計指針は、Gemini の Function Calling で構造化出力を実装する に詳しくまとめています。
どこで生成するか ― SSR・prebuild・ハイブリッドをコストで選ぶ
ここまでで「Server Endpoint(リクエスト時生成)」と「prebuild(ビルド時生成)」の両方が出てきました。どちらを使うかは好みではなく、コストとレイテンシで決めるべきです。私自身がコンテンツサイトで計測した体感値を、表に整理しておきます。
方式 生成タイミング 1記事あたりの実行回数 表示レイテンシ 向いている用途
リクエスト時 SSR ページ表示ごと PV に比例(青天井) +0.6〜1.2秒(Flash・短文) 入力が毎回変わるチャット・検索
prebuild 静的 ビルド時に1回 記事数ぶんだけ ほぼ 0ms(API コールなし) 要約・タグ・埋め込みなど確定値
ハイブリッド 確定値は事前・可変はSSR 記事数+可変分 用途ごとに最適 実運用のサイト全般
要約・タグ・埋め込みのように「同じ記事なら結果が変わらない」処理は、ページ表示ごとに API を叩く理由がありません。prebuild に寄せるほど、表示は速くなり、請求は記事数で頭打ちになります。リクエスト時 SSR は、入力が毎回変わるチャットや検索といった「事前計算できない」機能に絞るのが、私のおすすめです。
運用の判断を、私の体感値で3点に整理しておきます。
確定値(要約・タグ・埋め込み)は prebuild に寄せることを推奨します。表示は API コールなしのほぼ 0ms で返り、月の API 実行回数は記事数で頭打ちになります。
リクエスト時 SSR は表示ごとに 0.6〜1.2 秒の上乗せが避けられないため、本番運用ではチャットや検索など事前計算できない機能だけに絞ります。
prebuild を全記事一括で回すと、記事数に比例してビルドが伸びる落とし穴があります。差分再生成に切り替えると、1 本だけ直したコミットでは再計算対象が約 95% 減ることも珍しくありません。
prebuild をさらに効かせる鍵は差分だけ再生成する ことです。本文のハッシュを記録しておき、変わっていない記事は埋め込み計算ごとスキップします。これで記事数が増えても CI のビルド時間が線形に膨らみません。
// scripts/embeddings-incremental.ts(build-embeddings.ts の差分対応版)
import { createHash } from 'node:crypto' ;
import { readFile, writeFile } from 'node:fs/promises' ;
type Cache = Record < string , { hash : string ; vec : number [] }>;
function hash ( text : string ) : string {
return createHash ( 'sha256' ). update (text). digest ( 'hex' );
}
// 既存キャッシュを読み、本文ハッシュが一致する記事は再計算しない
async function loadCache ( path : string ) : Promise < Cache > {
try { return JSON . parse ( await readFile (path, 'utf-8' )) as Cache ; }
catch { return {}; }
}
export async function buildIncremental (
articles : { slug : string ; body : string }[],
embed : ( t : string ) => Promise < number []>,
cachePath = 'src/data/embeddings.json' ,
) {
const cache = await loadCache (cachePath);
let recomputed = 0 ;
for ( const { slug , body } of articles) {
const h = hash (body);
if (cache[slug]?.hash === h) continue ; // 変更なし → スキップ
cache[slug] = { hash: h, vec: await embed (body) };
recomputed ++ ;
}
await writeFile (cachePath, JSON . stringify (cache), 'utf-8' );
console. log ( `recomputed ${ recomputed }/${ articles . length } embeddings` );
}
たとえば200本の記事のうち1本だけ直したコミットなら、再計算は1本だけで済みます。埋め込みの単価は要約より安いとはいえ、毎ビルドで全記事を投げ直すのは無駄が大きく、CI の待ち時間にも直に効いてきます。
つまずきやすいポイント
実装するうえで、私自身が引っかかったり、相談を受けたりしたポイントをまとめておきます。
1. prerender = false を忘れる
Server Endpoint を作ったのに 404 や静的レスポンスが返るケースは、ほぼこれです。output: 'hybrid' ではデフォルト静的なので、SSR したいファイルには必ず export const prerender = false; を入れてください。
2. 環境変数が undefined になる
ビルド時スクリプト(scripts/ 配下)では import.meta.env ではなく process.env を使います。Astro ランタイムから外れた Node スクリプトだからです。.env の読み込みも dotenv/config を明示的に require する必要があります。
3. Cloudflare Workers / Pages にデプロイすると Node API が使えない
fs や path を使うビルドスクリプトはローカル / GitHub Actions 上で実行し、生成された JSON だけをリポジトリにコミットする運用が安全です。Workers 上では Gemini API を fetch 経由で呼ぶか、@cloudflare/workers-types 互換のアダプターに切り替えてください。
全体を振り返って:まずは「prebuild で要約生成」から始める
Astro × Gemini API の組み合わせは、ページを開くたびに API を叩く必要がない設計にできるのが最大の利点です。私のおすすめは、まず prebuild で記事要約だけ生成する運用から始めることです。これだけで RSS フィードや OGP 説明文の品質が一気に上がり、効果を体感しやすくなります。
そこから埋め込みベースの関連記事提案、自動タグ付け、検索機能と段階的に広げていけば、運用負荷を抑えながら AI 機能を育てられます。