Brigada agency website. Next.js 16 (App Router) on Cloudflare Workers via OpenNext, content managed in Sanity Studio v5.
- Frontend: Next.js 16, React 19, Tailwind 3, framer-motion 12, GSAP (ScrollTrigger), Lenis, HLS.js.
- CMS: Sanity v5 — Studio in
studio/, schemas + Presentation tool with Visual Editing. - Hosting: Cloudflare Workers via @opennextjs/cloudflare.
- Node.js >= 20
npmfor the frontend,pnpmfor the Studio- Sanity project credentials (see Environment)
# Frontend (port 3000)
npm install
npm run dev
# Studio (port 3333) — separate terminal
cd studio
pnpm install
pnpm devCopy .env.example to .env and fill in the Sanity variables, or use direnv with dotenv .env in your .envrc.
Two independent .env files: one at the repo root for the Next.js frontend, one inside studio/ for the Sanity Studio + its one-shot scripts. Keeping them split means the Studio can be deployed separately (e.g. manage.sanity.io or a different Cloudflare project) and stays self-contained.
If you use direnv, the root
.envrcdoesdotenv .env, so root env vars get exported into your shell andpnpm devinsidestudio/will inherit them — but don't rely on that for deploys or for teammates without direnv. Keepstudio/.envpopulated.
| Variable | Description |
|---|---|
NEXT_PUBLIC_SANITY_PROJECT_ID |
Sanity project ID. Public — appears in every CDN URL we serve. Read on both server and client so SSR + hydration build identical image URLs. |
NEXT_PUBLIC_SANITY_DATASET |
Dataset name, defaults to production. Same public reason. |
SANITY_API_VERSION |
Content Lake API version, defaults to 2026-04-08. |
SANITY_LOCALE |
Default locale code, en for now. |
SANITY_VIEWER_TOKEN |
Viewer-scoped read token. Required: some doc types (e.g. job, imported from Recruitee) aren't readable anonymously. Also enables Draft Mode preview drafts. |
SANITY_STUDIO_URL |
Studio base URL for stega click-through, defaults to http://localhost:3333. |
SANITY_WRITE_TOKEN |
Write-scoped token used by /api/contact and /api/jobs/apply to store form submissions as formSubmission docs. Without it, submissions return storage-unavailable. Keep server-side only — never expose in the client bundle. |
NEXT_PUBLIC_TURNSTILE_SITE_KEY |
Public site key for the Cloudflare Turnstile widget on the contact + job apply forms. When unset, the widget is skipped (dev convenience) and the API trusts the request — never leave unset in production. |
TURNSTILE_SECRET_KEY |
Secret used by the API routes to verify the Turnstile token via challenges.cloudflare.com/turnstile/v0/siteverify. Pair with the site key above. |
RESEND_API_KEY |
Optional. When set together with RESEND_FROM_EMAIL + CONTACT_EMAIL_TO, the API routes send a notification email via Resend after a successful submission. Failed sends never block the Sanity backup. |
MAILCOACH_API_URL |
Mailcoach API base, e.g. https://brigada.mailcoach.app/api. Used by /api/newsletter/subscribe to add visitors to the newsletter list. |
MAILCOACH_API_TOKEN |
Bearer token issued in Mailcoach → Settings → API tokens. |
MAILCOACH_LIST_UUID |
UUID of the target list (e.g. Brigada-Newsletter-External). Find it in the Mailcoach admin URL when you open the list. |
RESEND_FROM_EMAIL |
From: address for the notification email. Must be on a verified Resend domain (e.g. noreply@brigada.be). |
CONTACT_EMAIL_TO |
Recipient for the notification email (e.g. hello@brigada.be). |
Used by the Studio (pnpm dev, pnpm build, pnpm deploy) and by the one-shot scripts in studio/scripts/.
| Variable | Required for | Description |
|---|---|---|
SANITY_STUDIO_PROJECT_ID |
Studio + scripts | Project ID. |
SANITY_STUDIO_DATASET |
Studio + scripts | Dataset name, defaults to production. |
SANITY_STUDIO_PREVIEW_URL |
Studio Presentation | URL of the running Next.js app, defaults to http://localhost:3000. |
SANITY_API_WRITE_TOKEN |
seed.ts, sync-jobs.ts |
Write-scoped token for the one-shot scripts. Not used by pnpm dev. |
RECRUITEE_API_URL |
sync-jobs.ts |
Recruitee API base URL. |
RECRUITEE_COMPANY_ID |
sync-jobs.ts |
Recruitee company ID. |
RECRUITEE_API_TOKEN |
sync-jobs.ts |
Recruitee API token. |
GROQ queries run server-side in Server Components. The image URL builder needs project ID + dataset on the client too so the hydrated DOM matches what the server painted — hence the NEXT_PUBLIC_* prefix on those two values.
| Command | What it does |
|---|---|
dev |
Next.js dev server with Turbopack (http://localhost:3000). |
build |
Production build (.next). |
start |
Serve the production build locally. |
preview |
OpenNext build + Wrangler preview on a local Worker runtime. |
deploy |
OpenNext build + deploy to Cloudflare Workers. |
lint |
ESLint. |
types |
Re-extract the Sanity schema + regenerate TypeScript types. |
| Command | What it does |
|---|---|
dev |
Studio dev server (http://localhost:3333). |
build |
Production build of the Studio. |
deploy |
Deploy to the Sanity-hosted Studio URL. |
sync-jobs |
One-shot Recruitee → Sanity job import (studio/scripts/sync-jobs.ts). |
app/ Next.js App Router
├── layout.tsx Root layout: fonts, providers, metadata
├── providers.tsx Client-only providers (PageTransition, ScrollToTop, overlays)
├── page.tsx / → Concept (the homepage)
├── api/draft/{enable,disable}/route.ts Draft Mode endpoints
├── work/, work/[slug]/ /work index + case detail
├── careers/, careers/jobs/, careers/jobs/[slug]/
├── expertise/, brand/, product/, people/, marketing/
├── about/, contact/, privacy/, cookies/
└── not-found.tsx
src/
├── views/ Page-level client components rendered by app/ routes
├── components/ Shared components
│ ├── case-blocks/ Page-builder block renderers (RichText, ImageGrid, …)
│ ├── site/ SiteNav, SectionLabel, Reveal, …
│ └── DraftModeBanner.tsx Exit-preview pill + Visual Editing (iframe-gated)
├── lib/
│ ├── sanity.ts Server Sanity client + urlFor()
│ ├── sanity-fetch.ts GROQ helpers (getChrome, getHomePage, getWork, …)
│ └── site-chrome.ts React context for global chrome data
├── hooks/, types/
studio/
├── schemaTypes/ Sanity schemas
│ ├── documents/ work, job, expertise, person, location, menu, legalPage, …
│ ├── singletons/ homePage, aboutPage, careersPage, contactPage, workIndexPage, expertiseIndexPage, siteSettings
│ └── objects/ link, seo, socialLink, submenuItem + blocks/ (richText, sectionImage, imageGrid, videoEmbed, quote, statBlock)
├── structure/ Custom desk structure + per-type filtering
├── components/ Studio UI components (LocaleSlugInput, …)
└── scripts/ One-shot migrations, seed scripts, sync-jobs
Everything the frontend renders comes from Sanity — there are no hardcoded content fallbacks. Key docs:
work— case studies. Structured chrome (name,client,image,intro,expertises,year,code,gallery,related,seo) plus abody[]page-builder. Cases are linked fromhomePage.cases.items[].workand fromwork.related[].job— vacancies (kept in sync with Recruitee viastudio/scripts/sync-jobs.ts). Carry their ownformobject so each role can ship a custom apply form.expertise— the four pillars (Brand, Product, People, Marketing). Drive/brand,/product,/people,/marketing.person,location— referenced from pillars, contact + careers pages, job docs.legalPage— single doc type with akinddiscriminator (privacy,cookies,terms,imprint).menu— labelled byidentifier(main,footer), holdslink[]items.link— unified link object (target=internal | external | email | phone | anchor). Replaces the oldlinkItem/menuItem/legalLinkobjects.
Singletons (one per locale): homePage, aboutPage, careersPage, contactPage, workIndexPage, expertiseIndexPage, siteSettings.
The page-builder field is intentionally scoped to case detail only (work.body). The singletons stay structured (named fields, no free composition) so editors can't reshape page templates by accident. Available blocks:
| Block | Use |
|---|---|
richText |
Portable Text with H2/H3, bullets, etc. |
sectionImage |
Single image + caption + variant (inline / full-bleed). |
imageGrid |
2- or 3-column image grid. |
videoEmbed |
HLS URL or uploaded file + poster + aspect + autoplay. |
quote |
Pull quote with author + role. |
statBlock |
Numbers / stats grouped together. |
Add a new block type once in studio/schemaTypes/objects/blocks/, register it in pageBuilderField (helpers.ts), and add the matching renderer in src/components/case-blocks/.
Schemas are i18n-ready (each document carries a hidden locale field, fields that vary per language use internationalizedArrayString), but the site is currently English-only. studio/structure/constants.ts lists active locales — append a second entry and the studio + projection plumbing reactivates.
Toggle Draft Mode by hitting /api/draft/enable (and /api/draft/disable to exit). When Draft Mode is on, server-side fetches switch from the published-perspective client to a previewDrafts-perspective client with stega encoding enabled, so editor-facing field paths are embedded into string values.
<VisualEditing /> is only mounted when the page is loaded inside an iframe — i.e. inside Sanity Studio's Presentation tool. The live frontend never shows the click-to-edit overlay, even when Draft Mode is enabled directly. Editing is always initiated from Studio.
Cloudflare Workers via OpenNext. The Worker is configured in wrangler.jsonc:
main: .open-next/worker.js— built byopennextjs-cloudflare buildassets.directory: .open-next/assetscompatibility_flags: ["nodejs_compat"]
CI build/deploy commands:
Build: npm run build
Deploy: npx opennextjs-cloudflare deploy
Set the same SANITY_* environment variables in the Cloudflare dashboard.