Institutional Website and Headless CMS

Universidad Nacional de Colombia · 2026 - At present

Next.jsTurborepoNotion APIThree.js

The Prototyping Center needed a public site its own team could keep current — portfolio, equipment catalogue, people, FAQ, contact — without opening a code editor or waiting on a developer. The answer was to make Notion the CMS: the team edits the databases they already use, and the site syncs and republishes itself.

Architecture and Tech Stack

Core Architecture

  • Monorepo: Turborepo + pnpm workspaces, with shared dependency versions pinned in a single catalog: block
  • App: @cen/web — Next.js 16 (App Router), React 19, TypeScript, Server Components by default
  • CMS: Notion via @notionhq/client (API version 2025-09-03, data-source model) — no database of its own
  • Caching: the "use cache" directive with cacheTag + cacheLife("max"), refreshed only on demand
  • Images: Cloudinary, fed by an idempotent sync from Notion's file properties
  • Email: Resend for contact submissions, with @react-email/components templates and a @react-pdf/renderer attachment
  • 3D: Three.js for the hero robot, cobe for the globe — client-only imports
  • UI: shadcn/ui + Tailwind CSS 4, dark theme by default

Layered Architecture

Request Flow

Key Features

Features at a Glance

Notion as a headless CMS

Six databases hang off a single CMS page: Portfolio, Technologies, Team, FAQ, Configuration and Submissions. Every read filters by a Publicado checkbox, so publishing is a checkbox rather than a deploy. Portfolio entries are hybrid — structured properties drive the layout (hero, challenge, solution, tech stack) while the Notion page body is pulled as markdown and rendered with react-markdown + remark-gfm.

On-demand revalidation from a Notion button

Each database tab in Notion carries an "Actualizar" button wired to an authenticated revalidation endpoint that invalidates the cache tag for that database, so the editing loop is: edit → click → live. Called without a specific tag it refreshes every content tag at once.

Image sync that survives Notion's expiring URLs

Notion's uploaded-file URLs expire in about an hour, so they cannot be served directly. The editor uploads into a file property, and the sync uploads it to Cloudinary and writes the permanent secure_url back into the destination property. The Cloudinary public_id embeds a hash of the source file, so re-running the sync only re-uploads what actually changed.

Four-shape contact form

One form, four types (general, services, teaching visit, external visit), each with its own Zod schema and fields — including a dynamic student roster (useFieldArray) and a canvas signature pad. The resolver swaps schema with the active type.

One submission, two destinations

The server action validates, renders a PDF with the data, roster and signature, and then fires both destinations in parallel: an email to the Center through Resend (with the PDF attached and replyTo set to the requester, so answering is a normal Gmail reply) and a row in the Notion Solicitudes database that acts as the queryable record.

Technical Highlights

One version of React for every app

Cross-cutting dependency versions (react, next, typescript, tailwindcss, eslint, prettier) are pinned once in the workspace catalog: and referenced as "catalog:" in each package.json. Bumping a version is one line, and the "works in one app but not the other" class of bug disappears.

Explicit image hosts, never a wildcard

The image configuration lists each allowed host instead of a wildcard, so the optimizer can only pull from known origins. It is the kind of default that looks harmless in a config file and isn't.

The formal artifact and the record are separate on purpose

The signed PDF lives in the Gmail thread, where the reply also happens; Notion holds the structured row for tracking. Each destination is a separate concern on purpose: the formal artifact belongs where the conversation is, the record belongs where the follow-up is.

Hardcoded copy is a deliberate, bounded exception

Decorative copy that almost never changes (hero, CTA, capability strip, STEM points, portfolio process steps) stays in code, while technologies, impact metrics, contact details and mission/vision are single-sourced in Notion. The rule is written down in the architecture doc so the two never drift into duplicates.

Project Structure

cenprototipado/
├── apps/
│   └── web/                     @cen/web — public site
│       ├── app/(marketing)/     /, /centro, /portafolio[/slug], /tecnologias, /contacto
│       ├── app/api/revalidate/  on-demand cache invalidation + image sync
│       ├── components/sections/ hero, robot 3D, cases, gallery, contact wizard
│       └── lib/
│           ├── notion/          client, property mappers, one fetcher per database
│           ├── cloudinary/sync  Notion file property → Cloudinary → url property
│           ├── forms/           Zod schemas + form config per type
│           ├── pdf/             @react-pdf/renderer submission document
│           └── email/           Resend client + React Email template
├── packages/                    ui / auth / db — shared, currently scaffolded
├── turbo.json
└── pnpm-workspace.yaml          catalog: shared dependency versions

Impact and Scalability

  • The team publishes and updates content on its own; a copy change no longer needs a developer or a deploy.
  • Content is cached indefinitely and only refreshed on demand, so the site serves static pages while still being editable.
  • The monorepo is laid out for the Center's other apps to land alongside this one on the same shared packages, with the identity, storage and database model for that step already decided in the architecture doc.

Notes

Built with Next.js 16, Turborepo, the Notion API as CMS, Three.js, Cloudinary, Resend and shadcn/ui. Code is public on GitHub and the site is live at cenprototipado.vercel.app.


© 2026 Felipe Giraldo