Case study
Saravá — a zero-cost cultural hub with real per-post SEO
A real cultural product, not a template: Espacio Cultural Saravá is a community center in Bolívar, Buenos Aires that needed one digital home for its radio, podcast, reading club, and cultural posts. The engineering story is how a non-technical collective gets shareable, SEO-correct pages with zero hosting cost—Next.js static export to GitHub Pages, dynamic [slug] routes with per-entry OG metadata, and a Zod content schema that refuses to deploy bad data.
1. Problem
A grassroots cultural collective has rich content—live radio, a podcast, a reading club, weekend workshops, cultural news—and almost no budget. A WordPress install means recurring hosting plus a security surface nobody will maintain; a single static landing page means each cultural post has no URL of its own, so nothing is shareable or indexable. The people who actually publish are organizers and writers, not developers.
So the brief was a product brief, not just a build: every post needs its own page with a correct title and social preview; publishing must be possible without a CMS or a database; hosting must cost nothing to run; and a bad content edit must never reach production. The engineering exists to serve those constraints—not the other way around.
2. Architecture
Content lives as JSON in the repo, typed server-only loaders turn it into data, the App Router renders section and per-post pages, and the whole thing is exported to static HTML that GitHub Pages serves for free:
content/ next build (output: "export")
├─ posts/*.json ┐ Zod ┌────────────────────────────┐
├─ novels/*.json ├─ validate ─▶│ Next.js App Router │
└─ events/*.json ┘ (CI gate) │ server-only loaders │
│ │
lib/content.ts │ /radio-streaming │
getPosts() · getEvents() │ /podcast · /club-lectura │
getNovels() (typed) │ /espacio-cultural │
│ /espacio-cultural/[slug] │
└─────────────┬──────────────┘
│ static HTML + assets
▼
out/ ──▶ docs/ ──▶ GitHub Pages
(zero server cost)
- content/*.json — posts, novels, and events as plain JSON files. The filename is the slug; editors add a post by adding a file in a pull request.
- lib/content.ts — server-only typed loaders (getPosts, getPostBySlug, getEvents, getNovels) that read the folders and sort by date; no client ever touches the filesystem.
- app/ — App Router section routes (radio-streaming, podcast, club-lectura, espacio-cultural, sobre-nosotras) plus the dynamic [slug] post route, all sharing a SiteHeader/SiteFooter shell.
- output: "export" — the build emits pure static HTML to out/, copied into docs/, which GitHub Pages serves. No Node server, no database, nothing to keep running.
3. Dynamic routes & SEO
Each cultural post gets a real page at build time. generateStaticParams enumerates every slug, and generateMetadata reads that post's frontmatter to emit a per-entry title, description, and OG image—so a link pasted in WhatsApp or Slack previews correctly, and search engines index each post on its own URL.
// app/espacio-cultural/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug })); // one page per post
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return { title: "Publicación no encontrada" };
return buildPageMetadata({ // canonical + OG + Twitter card
title: post.title,
description: post.excerpt,
path: `/espacio-cultural/${slug}`,
image: post.image, // real per-post social preview
});
}
- Static params from data — generateStaticParams maps getPosts() to slugs, so the route set is derived from content, not hardcoded; add a JSON file and the page exists.
- Per-entry metadata — generateMetadata builds title, description, canonical URL, and OG/Twitter image from the post itself, via a shared buildPageMetadata() helper.
- Honest 404s — an unknown slug calls notFound() instead of rendering an empty shell, and metadata falls back to a clear "post not found" title.
- List → detail — the espacio-cultural index links each card to /espacio-cultural/{slug}, so the list and the indexable detail pages stay in sync.
4. Content pipeline
With JSON-in-Git as the CMS, the schema is the quality gate. A Zod schema describes every collection, and a validation script runs in CI before the build—rejecting a malformed date, a missing field, a non-kebab-case filename, or an image reference that doesn't exist on disk. Bad content fails the pipeline instead of shipping silently.
// lib/content-schemas.ts
export const postSchema = z.object({
title: z.string().min(1, "title is required"),
author: z.string().min(1, "author is required"),
date: isoDate, // YYYY-MM-DD, real calendar date
excerpt: z.string().min(1, "excerpt is required"),
tags: z.array(z.string().min(1)).min(1),
image: z.string().min(1, "image is required"),
});
// scripts/validate-content.ts — fails CI on bad data
const result = schema.safeParse(raw);
if (!result.success) collect(result.error.issues); // path + message
// also: filename must be kebab-case, image must exist in public/
- Schema per collection — postSchema, novelSchema, eventSchema (and a podcast schema) encode required fields, URL validity, and a strict YYYY-MM-DD date that must parse to a real day.
- Broken-image guard — for image fields, the script resolves the path under public/ and fails if the asset is missing, so a renamed file can't ship a dead image.
- Slug discipline — the filename must be lowercase kebab-case (it becomes the URL), validated by the same slug regex the routes rely on.
- Wired into CI — npm run ci is lint → validate:content → build, so the production build never runs against content the schema rejects.
The deliberate trade-off: editors publish through Git pull requests rather than a point-and-click admin. For a small collective that's a feature, not a flaw—versioned history, free review, and a schema that catches mistakes—at the cost of asking contributors to learn a minimal Git flow.
5. Static export & zero-cost hosting
The same config that makes hosting free also keeps SEO intact. output: "export" emits a fully static site; a base path scopes every asset and link to the GitHub Pages project URL; and a tiny script copies the build into docs/, which Pages serves directly.
// next.config.ts
const repoName = "sarava-radio-streaming";
const isProd = process.env.NODE_ENV === "production";
const nextConfig = {
output: "export", // pure static HTML, no server
basePath: isProd ? `/${repoName}` : undefined,
assetPrefix: isProd ? `/${repoName}/` : undefined,
trailingSlash: true, // /post/ → post/index.html
images: { unoptimized: true }, // no image-optimizer server
};
// build:pages → next build → copy out/ → docs/ → GitHub Pages
- Pure static output — output: "export" produces prerendered HTML for every route, so there is no serverless function or container to pay for or patch.
- Project-path correctness — basePath and assetPrefix prefix the repo name in production so links, assets, and the NEXT_PUBLIC_BASE_PATH images resolve under /sarava-radio-streaming/.
- Pages-friendly URLs — trailingSlash: true emits folder/index.html per route, which GitHub Pages serves cleanly without rewrites; images are unoptimized since there's no optimizer at runtime.
- Deterministic deploy — build:pages runs next build then copies out/ into docs/; pushing docs/ is the deploy. No pipeline secrets, no host to provision.
Honest scope: static export rules out SSR, ISR, and API routes—data is fixed at build time, not per request. That's the right call for a content site that changes at editor speed, and the clear upgrade path (if the collective ever needs live data) is moving those routes to a hosted runtime.
6. Outcomes
- Zero hosting cost — a real, client-ready cultural site running on GitHub Pages with no server, database, or recurring bill.
- Shareable, indexable posts — each cultural entry has its own static URL with a correct title and OG image, so links preview properly and search engines can index them.
- Non-dev publishing with guardrails — organizers add content as JSON via pull requests, and the Zod schema + validate script block malformed data, broken dates, or missing images before deploy.
- Live on GitHub Pages—browse the radio, podcast, reading club, and per-post cultural space: alejosworkstuff.github.io/sarava-radio-streaming