Institutional Website and Headless CMS
Universidad Nacional de Colombia · 2026 - At present
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 version2025-09-03, data-source model) — no database of its own - Caching: the
"use cache"directive withcacheTag+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/componentstemplates and a@react-pdf/rendererattachment - 3D: Three.js for the hero robot,
cobefor 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.