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.

Saravá Espacio Cultural site

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:

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
  });
}

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/

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

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