●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20●NANOLITE — Nano Banana 2 Lite is here: Google's fastest and most cost-efficient Gemini Image model, made for running lightweight image generation cheaply●OMNIFLASH — Gemini Omni Flash is in public preview, a natively multimodal model that lets enterprises and developers build custom, dynamic video workflows●AGENTS — Managed Agents expand with background: true for async server-side runs and polling, remote MCP server integration, and refreshing credentials across interactions●MEMORY — The Memory Bank IngestEvents API is generally available, decoupling event ingestion from memory generation so you can stream content continuously●THROUGHPUT — Provisioned Throughput now lets you submit up to seven pending orders for the same model and region●DEPRECATE — Image generation models shut down on August 17, and the Grok 4.1 family on the Gemini Enterprise Agent Platform on August 20
"I'm running my blog on Astro, but every Gemini tutorial I find assumes Next.js with app/api." This is a question I've been hearing more and more lately. Astro leans toward static-first by design, so the standard Next.js patterns for AI features don't translate cleanly, and many developers get stuck.
I run a content site on Astro myself, and pairing it with the Gemini API has been a pleasant experience. You get to keep Astro's lightness and speed while adding AI summaries, related-article suggestions, and auto-tagging without much friction. This post walks through how I do it, with working code you can actually run.
Why Astro and Gemini API Pair Well
Astro is fundamentally a static site generator, but switching output to 'server' or 'hybrid' lets you opt individual pages into server execution. AI features that need per-request generation can use this targeted SSR — only the pages that need it pay the dynamic cost, and the rest of your site stays as fast as a pure static build.
What I particularly like is how Astro's Content Collections (src/content/) give you type-safe access to Markdown/MDX metadata. Pour Gemini-generated summaries and related tags into the schema, and your existing list and category pages just keep working.
Here's the minimal project setup, assuming Astro 5.
# Create a new projectnpm create astro@latest my-ai-blog -- --template minimal --typescript strictcd my-ai-blog# Add the official Gemini SDK and Node adapternpm install @google/genainpx astro add node
Configure astro.config.mjs like this. Choosing output: 'hybrid' gives you static-by-default with opt-in SSR per page.
// astro.config.mjsimport { defineConfig } from 'astro/config';import node from '@astrojs/node';export default defineConfig({ output: 'hybrid', // Static by default, SSR where needed adapter: node({ mode: 'standalone' }), server: { port: 4321 },});
Manage the API key via .env. Astro reads it through import.meta.env, but it's important to omit the PUBLIC_ prefix so it stays server-only.
# .envGEMINI_API_KEY=YOUR_GEMINI_API_KEY
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A decision framework for choosing per-request SSR, prebuild static, or hybrid generation by cost and latency
✦Auto-tagging that closes the vocabulary with a responseSchema enum to stop spelling drift and duplicate tag pages
✦Incremental embeddings keyed on a body hash so CI build time stays flat as your article count grows
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
In Astro, any file under src/pages/api/ becomes a REST endpoint. The shape is similar to Next.js Route Handlers but uses the APIRoute type.
Here's a minimal endpoint that takes article body text and returns a summary. I use this for both list pages and OGP descriptions when sharing to social.
// src/pages/api/summarize.tsimport type { APIRoute } from 'astro';import { GoogleGenAI } from '@google/genai';const ai = new GoogleGenAI({ apiKey: import.meta.env.GEMINI_API_KEY });export const prerender = false; // Enable SSR for this endpointexport 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: `Summarize the following article in 140 characters or less. No decorative preface — just the key points.\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 }); }};
Don't forget prerender = false. Without it, the file gets statically built and you lose per-request generation. The Astro philosophy is exactly this: opt into SSR explicitly only for files that need it.
The behavior is straightforward. POST {"content": "..."} to /api/summarize and you get back {"summary": "..."}.
curl -X POST http://localhost:4321/api/summarize \ -H "Content-Type: application/json" \ -d '{"content": "Astro is a static site generator that..."}'# → {"summary": "Astro is a static site generator..."}
Pairing with Content Collections
Astro really shines once you bring Content Collections into the picture. Define the schema for article metadata, and Gemini-generated summaries and auto-tags become type-safe right away.
// src/content/config.tsimport { 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(), // Generated by Gemini aiTags: z.array(z.string()).default([]), }),});export const collections = { blog };
If you want to process all articles in bulk, the practical approach is to run a script before astro build. This way you avoid hitting the API on every page request, keeping both cost and latency predictable.
Now npm run build auto-summarizes only unprocessed articles before the production build runs. You only pay the AI cost once per article.
Recommending Related Articles with Gemini Embeddings
Beyond summaries, "automatic related-article links" are also straightforward in Astro. The standard approach is to vectorize each article with gemini-embedding-001 and rank by cosine similarity.
Load this embeddings.json from an Astro component and compute cosine similarity at render time.
---// src/components/RelatedArticles.astroimport { 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>Related</h2> <ul> {related.map(r => r.entry && ( <li><a href={`/blog/${r.entry.slug}`}>{r.entry.data.title}</a></li> ))} </ul></aside>
Computed once at build time, this means production pages render instantly with no API calls. If you cross several thousand articles, you'll want to move the vectors into SQLite and switch to ANN search, but for personal blogs the in-memory approach is more than enough.
Stabilizing the "Auto-Tagging" I Promised, with Structured Output
With summaries and related articles in place, let's implement the last of the three pillars I opened with — auto-tagging. If you let the model write tags freely, you will get drift: AI, ai, and artificial intelligence end up coexisting and your tag pages fragment. Running a content site as an indie developer, I've lost plenty of quiet hours cleaning up exactly this kind of drift.
There are two keys to preventing it: constrain the output with a JSON schema, and force the model to pick from an existing tag vocabulary. Gemini's responseMimeType: 'application/json' plus responseSchema lets you lock down the type of every array element.
// scripts/auto-tag.tsimport { GoogleGenAI, Type } from '@google/genai';const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });// The canonical tag vocabulary — the core that pins each tag to one spellingconst 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: `Pick up to 4 tags for the article below. ` + `Choose only from this vocabulary and never invent new terms. ` + `Vocabulary: ${TAG_VOCABULARY.join(', ')} / Title: ${title} / Body: ${body.slice(0, 4000)}`, }], }], config: { temperature: 0, // tagging should be deterministic 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[] }; // Final filter in case anything outside the enum slips through (belt and suspenders) return parsed.tags.filter(t => TAG_VOCABULARY.includes(t));}
Two things matter here. temperature: 0 makes tagging deterministic, and passing the vocabulary to items.enum stops the model from inventing new terms. Since I switched to this "closed vocabulary" design, duplicate tag pages caused by spelling drift have all but disappeared. The enum still isn't a hard guarantee, so the final TAG_VOCABULARY.includes filter is cheap insurance.
Where to Generate: Choosing SSR, prebuild, or Hybrid by Cost
We've now seen both "Server Endpoint" (per-request) and "prebuild" (build-time) generation. Which one to use isn't a matter of taste — decide it on cost and latency. Here are the ballpark figures I measured running my own content site.
Approach
When it runs
Calls per article
Render latency
Best for
Per-request SSR
Every page view
Scales with traffic (unbounded)
+0.6-1.2s (Flash, short text)
Chat / search where input changes each time
prebuild static
Once at build time
Once per article
~0ms (no API call)
Summaries, tags, embeddings — fixed values
Hybrid
Fixed values ahead, variable via SSR
Article count + variable
Optimal per use case
Most production sites
Work like summaries, tags, and embeddings — where the same article always yields the same result — has no reason to hit the API on every view. The more you push toward prebuild, the faster pages render and the more your bill caps out at article count instead of traffic. I reserve per-request SSR for features that genuinely can't be precomputed, like chat and search.
The trick that makes prebuild scale is recomputing only what changed. Record a hash of each body and skip unchanged articles, embeddings and all. That keeps CI build time from growing linearly as the article count climbs.
// scripts/embeddings-incremental.ts (a diff-aware version of 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');}// Load the existing cache; articles whose body hash matches are not recomputedasync 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; // unchanged -> skip cache[slug] = { hash: h, vec: await embed(body) }; recomputed++; } await writeFile(cachePath, JSON.stringify(cache), 'utf-8'); console.log(`recomputed ${recomputed}/${articles.length} embeddings`);}
If a commit touches just one article out of 200, only that one is recomputed. Embeddings are cheaper than summaries, but re-sending every article on every build is wasteful — and it shows up directly in your CI wait time.
Common Stumbling Blocks
A few issues I've personally hit, or have helped others debug:
1. Forgetting prerender = false
If your Server Endpoint returns 404 or a static response, this is almost always the cause. In output: 'hybrid' mode, files are static by default — every endpoint that needs SSR must explicitly opt in with export const prerender = false;.
2. Environment variables coming back as undefined
In build-time scripts (under scripts/), use process.env, not import.meta.env. They run as plain Node scripts outside the Astro runtime. You'll also need to explicitly require dotenv/config to load .env.
3. Node APIs unavailable on Cloudflare Workers / Pages
Build scripts that use fs or path are safest to run locally or in GitHub Actions, then commit the generated JSON to the repo. On Workers, call Gemini via fetch directly, or use a @cloudflare/workers-types-compatible adapter.
Wrapping Up: Start with prebuild Summaries
The biggest payoff of pairing Astro with Gemini API is that you don't need to hit the API on every page load. My recommendation: start with just prebuild-time summary generation. Even that alone meaningfully improves your RSS feed and OGP descriptions, and the impact is easy to feel quickly.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.