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

POST /api/revalidate?secret=…&tag=… validates a shared secret and calls revalidateTag. Each database tab in Notion carries an "Actualizar" button wired to that endpoint, so the editing loop is: edit → click → live. Without a tag it refreshes all five content tags.

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

images.remotePatterns lists each allowed host instead of **. A wildcard there turns Next's image optimizer into an SSRF vector — 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. Known v1 risk, stated rather than hidden: if one destination succeeds and the other fails there is no compensation, and the user just gets a generic error.

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 (internal dashboard, LMS) to land as apps/* on the same shared packages; the architecture doc already specifies the intended identity (self-hosted Logto), storage (Cloudflare R2) and one-schema-per-app Supabase model for that step.

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