React + SCSS front end for the CodeBoxx corporate site (Studio, Solutions, Academy, Ventures),
built with Astro, react-bootstrap and a brand-themed SCSS system.
Migrated from a Vite/React SPA to Astro (branch ml/astrobuild_migration) — every
route, including the 404 page, is native Astro; the old SPA code has been removed.
- Astro 5 (static output —
astro buildprerenders every route to real HTML indist/, no adapter/server) + React 18 islands for interactive pieces, via@astrojs/react - react-bootstrap + Bootstrap 5 for every interactive component (nav, forms, badges, the Codi/Enroll drawers), themed via Sass variable overrides — no vendored component bundle
- SCSS for all styling, no inline styles (
src/styles/) - Sanity CMS, fetched at build time for every native page (
src/lib/sanityContent.js, zero client JS for the content itself) - Academy start dates from the school portal, through the relay's
GET /api/cohorts, also at build time (src/lib/cohorts.js, see "Academy start dates" below) - @astrojs/sitemap generates
sitemap-index.xml/sitemap-*.xmlfrom the actual prerendered routes (including one entry per blog post) — replaces the old hand-rolledscripts/generate-sitemap.mjs
This migration exists to fix a real bug, not just chase a framework trend: the old SPA's
Seo.jsx (react-helmet-async) only ever wrote OG/Twitter/JSON-LD tags into the DOM
client-side, so a shared blog link's social preview and hreflang alternates were invisible
to anything that doesn't execute JS (most social link-unfurlers, many non-Google crawlers).
Astro renders that straight into the HTML response instead.
| Route(s) | Status | Notes |
|---|---|---|
/blog, /fr/blogue |
✅ Native Astro | src/pages/blog/index.astro + FR twin. Full post list fetched at build time, filter/pagination is a React island (BlogPostsIsland.jsx) hydrated over already-rendered markup. |
/blog/:slug, /fr/blogue/:slug |
✅ Native Astro | src/pages/blog/[slug].astro + FR twin, via getStaticPaths. Post body renders through src/lib/portableText.js (a hand-written server-side Portable Text → HTML renderer) — zero JS for the article itself, not even a React island, since that would reintroduce the exact bug this migration fixes. |
/, /fr |
✅ Native Astro | src/pages/index.astro + FR twin, via HomeIsland.jsx — see "Why Home is one big island" below. Real SEO tags and all of Home's actual content (hero, division blurbs, team names/photos, service details, academy copy) are now in the HTML response; it's still a fully interactive page after hydration, same as before. |
/financing, /fr/financement |
✅ Native Astro | src/pages/financing.astro + FR twin. Zero React islands beyond Layout.astro's TopBar/Footer — every "form" field here was already uncontrolled and both buttons just navigate, so the whole page body is static HTML with zero extra JS, same treatment as Blog's static sections. |
/ventures, /fr/ventures |
✅ Native Astro | src/pages/ventures.astro + FR twin. Only the "Pitch us" button + its drawer need client state, so that's one small island (PitchWidget.jsx) — everything else is static, same TopBar/Footer islands as Blog. |
| unmatched paths (404) | ✅ Native Astro | src/pages/404.astro on Layout.astro: bilingual "Page not found" message with links to the EN and FR homepages, noindex. nginx serves dist/404.html for any unmatched URL. |
Blog/BlogPost got split into fine-grained islands (or zero JS at all, for the post
body) because most of their content is genuinely static. Home isn't: Studio/
Solutions/Academy are tab-switchers, there's a chat drawer, a full enrollment form,
a contact form, a carousel, a count-up animation — and the Codi/Enroll drawers need
to share state with both TopBar (top of the page) and several buttons deep inside
the content (Academy, Contact). That only works cleanly if TopBar, the content, the
drawers, and Footer are all one React tree — so HomeIsland.jsx mounts as a single
client:load island from src/pages/index.astro, importing TopBar/Footer
directly from ChromeIsland.jsx as plain nested components (not separate Astro
islands the way Blog's Layout.astro uses them). Astro still server-renders this
island's first pass into real HTML same as any other island, which is what fixes
the SEO/crawlability problem — the trade-off, honestly stated, is that Home ships a
real JS bundle for its interactivity same as before; this migration's win here is
crawlability and removing the Sanity-content pop-in, not a zero-JS page.
git clone git@github.com:CodeBoxxTechSchool/CodeBoxx-Claude-Website.git
cd CodeBoxx-Claude-Website
npm install
cp .env.example .env # fill in the Sanity values
npm run devnpm run build writes the static site to dist/; npm run preview serves it.
The Academy page (/academy, /fr/academie: its Starts list, its <title> and its Course
structured data) and /llms.txt show the school portal's start dates, read once per build by
src/lib/cohorts.js from the relay's GET https://codeboxx.ai/api/cohorts (relay/README.md).
Only starts after today (UTC) and at most 90 days later are shown; the AI program is on demand and
shows none. Two variables, from the environment or .env:
| Variable | Use |
|---|---|
COHORTS_URL |
Where to read them, default https://codeboxx.ai/api/cohorts |
COHORTS_FILE |
A local JSON file holding the same answer, read instead of the URL (offline, or a sample) |
{
"ok": true,
"cohorts": [{ "program": "fsd", "start": "2026-11-09" }],
"asOf": "2026-10-13T14:00:00Z"
}Local builds read COHORTS_URL; the deploy reads the relay over SSH and passes COHORTS_FILE
(see Deployment). A cohort of a program the site doesn't show is skipped with a warning.
npm run build fails when the dates can't be had (network, timeout after 15 s, any status but
200, a 503 from the relay, an answer of another shape), so a deploy stops and the live site keeps
the dates it has. npm run dev warns and shows the page without dates. A copy the relay got from
the portal more than two days ago (asOf) gets a warning in the build log, not a failure.
| Route | Source | Notes |
|---|---|---|
/ |
src/pages/index.astro → HomeIsland.jsx |
Sections 01-07, WSJ and Forge 20 bands, Codi drawer, enroll drawer |
/blog |
src/pages/blog/index.astro |
CodeBlog index -- Sanity fetched at build time |
/blog/:slug |
src/pages/blog/[slug].astro |
Standalone post page -- one prebuilt page per post |
/financing |
src/pages/financing.astro |
Academy financing options -- fully static, no React island |
/ventures |
src/pages/ventures.astro -> PitchWidget.jsx |
CodeBoxx Ventures -- one small island for the pitch form |
/careers |
src/pages/careers.astro -> CareersForm.jsx |
Careers -- one island for the application form (CV upload) |
(Each has an /fr/... twin — /fr, /fr/blogue, /fr/blogue/:slug, /fr/financement,
/fr/ventures, /fr/carrieres — as its own separate page file, matching how ROUTE_TABLE
already modeled EN/FR as distinct paths rather than one parameterized locale route.)
src/lib/sanityContent.js is the build-time Sanity client every native page uses: plain
async functions (fetchPostList, fetchPostBySlug, fetchAllPostSlugs, fetchTeam,
fetchLogos) called from Astro frontmatter/getStaticPaths, so content is already
resolved into the HTML by the time it ships — no hook, no loading state.
src/lib/sanity.js is the browser-side counterpart (today only pickLocale, for the landing
pages' islands, is used). Both query the Sanity Content API directly over
fetch (no SDK dependency) and fall back to a hardcoded seed array/object
(src/lib/blogSeed.js) when VITE_SANITY_PROJECT_ID isn't set, so the site always builds
and runs with no CMS connection. Entry mapping lives in each file's own toPost()-shaped
function — adjust field names there if the document type's schema differs (both files need
the same edit until sanity.js is retired).
Project: zagi8xr3 ("CodeBoxxWeb", dataset production). The dataset allows public reads,
so VITE_SANITY_TOKEN can stay blank — only set it if the dataset is ever made private.
The editing dashboard (Sanity Studio) lives in its own project, not in this repo:
/projects/codeboxx-website-studio, already attached to project zagi8xr3
with the schema types below.
- Locally:
cdinto that folder,npm run dev, openhttp://localhost:3333. - Deployed (the real day-to-day dashboard, reachable from anywhere): https://codeboxxweb.sanity.studio/
- Project settings, API tokens, dataset management (not content editing): https://www.sanity.io/manage/project/zagi8xr3
Document types expected in the Sanity Studio project (created separately — this repo only consumes the API, it doesn't scaffold a Studio):
| Document type | Fields | Consumed by |
|---|---|---|
post |
title, slug, category, author, publishedAt, excerpt, content (Portable Text/rich text), featuredImage (optional), url (optional external reference — not the content source) |
useSanityPosts — Blog page; useSanityPost(slug, seed) — the post's own page at /blog/:slug |
teamMember |
name, role, linkedin (url), photo (image), group ("studio" | "academy"), order (number) |
useSanityTeam(group, seed) — Studio team (#codeboxx .person) and Academy team (#academy .person), same type filtered by group |
partnerLogo |
name, logo (image), order (number) |
useSanityLogos(seed) — the .client-slider partner logos; however many documents exist is however many slides show |
Each post's content field is Sanity's standard Portable Text (rich text): headings,
bold/italic, links, lists, quotes, tables (the @sanity/table Studio plugin), and a
videoEmbed block (a YouTube/Vimeo URL, not an uploaded file — see below). Still no
inline images; that's a deliberate, easy-to-extend-later scope call, not a limitation
of the approach. It renders via portableTextToHtml() in src/lib/portableText.js — a
hand-rolled walker (no @portabletext/react dependency) that returns a plain HTML
string Astro inlines at build time, mapping block/list/mark/table/video types onto this
site's existing typography classes (pbody, h2, ptitle, etc.) instead of unstyled
defaults.
A videoEmbed renders as a click-to-play facade for YouTube (a static
i.ytimg.com thumbnail + play button; the real iframe — and YouTube's own heavy
embed JS — is only created on click, via the small inline script in
src/pages/blog/[slug].astro) or a loading="lazy" iframe for Vimeo (no
API-free static thumbnail exists for it). This is the actual "auto-optimize"
lever: neither format is ever stored/transcoded in Sanity — see Sanity's own
video guidance (no file-asset video at scale) — the source platform's
adaptive streaming does that job instead.
featuredImage (optional) is the post page's hero background, via inline style
since the URL is per-post data — stacking a linear-gradient(rgba(0,0,0,.6), ...)
scrim with url(...) in one background-image, reusing the scrim-over-photo
technique already used by the homepage .hero (_components.scss). It also backs
the /blog listing card's cover (BlogPostsIsland.jsx). sanityImageUrl(url, { w, q }) in sanity.js
appends Sanity's CDN resize/quality query params so each use requests only the pixel
size it renders (large for the hero, small for the card thumbnail) — no
@sanity/image-url package needed. Posts without a featuredImage keep the plain
navy hero, unchanged. The article section below the hero (.post-content-band in
_blog.scss) has its own subtle top-fade gradient, independent of featuredImage —
plain CSS, not photo-based.
Team member and partner logo entries carry a stable id (the Sanity document _id) that's
used as the React list key. Don't switch that back to an array index — a positional key
would let React reuse the wrong person's DOM (and photo) after a reorder in Studio.
Everything routes through src/styles/, orchestrated by main.scss (@use, in order):
_variables.scss brand color ramps, semantic aliases, typography/radius scale, then the
Bootstrap scalar + $theme-colors overrides, then `@import 'bootstrap/scss/bootstrap'`
_base.scss html/body resets
_typography.scss .eyebrow/.h2/.lede/.ptitle/.pbody + band/pill/hero text styles
_layout.scss .wrap/.sect/.grid2/.grid3/.grid4 — the site's own layout primitives
_components.scss .panel/.rule/.btn-ghost/badge tint overrides/forms — shared across pages
_chrome.scss header nav dropdown + footer
_home.scss Home-only sections (hero, tabs, metrics, calendar, Codi/Enroll drawers)
_blog.scss Blog-only (post cards, category pills)
_static-pages.scss shared by Financing.jsx + Ventures.jsx
_variables.scss has a header comment explaining why it mixes @import/@use and why
overrides must precede bootstrap/scss/variables — read it before touching Bootstrap theming.
Brand components with no Bootstrap equivalent (Logo, Avatar) are tiny hand-written
components in src/components/, not a vendored bundle. Everything else — buttons, badges,
forms, the Codi/Enroll drawers — is react-bootstrap, restyled via the SCSS above.
Every hero/intro band — Home's .hero, and the .band-dark band on Blog/Financing/
Ventures — plays a looping background video via src/components/HeroVideo.astro
(dropped in as the first child of the section; .hero/.band-dark already carry the
positioning/stacking CSS for it in _components.scss/_home.scss). BlogPost's hero
gets it too, but only for a post with no featuredImage of its own — a post's specific
photo is more relevant content than a generic loop, so it takes priority (see the
{!post.featuredImage && <HeroVideo />} check in src/pages/blog/[slug].astro).
Assets live in public/assets/: hero-video-bkg.webm (VP9, ~1.3 MB, tried first),
hero-video-bkg.mp4 (H.264, ~2 MB, fallback for browsers that can't decode WebM), and
hero-video-poster.webp (a single frame, shown while the video loads and used by the
<video poster> attribute). All three are re-encodes of an original 8.5 MB source clip —
re-encoding cut it by 76-85% with no visible quality loss (it's a smooth, motion-blurred
abstract loop, which compresses very well); re-run the same ffmpeg settings (see git
history on these files) if the source clip is ever replaced.
The video is intentionally not shown below the tablet breakpoint or under
prefers-reduced-motion (see the @media rule at the bottom of the hero-video block in
_components.scss) — phones fall back to each section's existing static
background-image/color instead, to avoid the extra data/battery cost of an autoplaying
video on mobile networks.
Every <h2> on every native page fades/slides in the first time it scrolls into view,
via src/components/ScrollReveal.astro — plain vanilla JS (an IntersectionObserver
adding an .is-inview class), not a React island, specifically so it works identically
on native Astro pages (no React at all) and on Home (one big React island) without
caring which produced a given <h2>. It's included once per page shell: Layout.astro
(Blog/BlogPost/Financing/Ventures) and directly in src/pages/index.astro/fr/index.astro
(Home, which doesn't use Layout.astro).
A MutationObserver in the same script also watches for <h2>s added after the fact —
Blog's category filter and "load more"/infinite-scroll pagination
(BlogPostsIsland.jsx) mount post-card titles well after the page first loads, and
without this they'd never get observed at all and would stay invisible forever, not
just unrevealed until scrolled to.
The CSS (_components.scss, "Scroll-reveal fade-in for section h2 titles") only hides
an <h2> under prefers-reduced-motion: no-preference — reduced-motion visitors see
every title in place immediately, no animation, no JS dependency at all. The
<noscript> block in ScrollReveal.astro covers the other no-JS case (disabled, or the
script failing to load): without it, a hidden title with nothing left to add
.is-inview would stay invisible forever too.
.github/workflows/deploy.yml builds the site and rsyncs dist/ to the
"CodeBoxx Web Claude" DigitalOcean Droplet over SSH on every push to main, on every Sanity
publish (below), once a day at 09:15 UTC (so the Academy's start dates enter and leave their
90-day window on time, and new portal cohorts show within a day), or manually via "Run
workflow". After the tests, it deploys relay/ first, so a relay fix can always ship (and a
relay change is live even if the site's build then fails); then it reads the start dates from the
relay over SSH on the droplet (curl http://127.0.0.1:8787/api/cohorts, not through Cloudflare or
nginx) into the file the build reads as COHORTS_FILE, builds, and rsyncs dist/. A relay that
can't answer (503, error) or a build that can't use the dates stops the run before dist/ is
deployed (see "Academy start dates" above). GitHub disables a scheduled workflow after 60 days without
activity in the repository; re-enable it in the Actions tab. It does not provision anything —
the Droplet, nginx, the deploy user, and the target directory are already set up (see below).
Required repo secrets (Settings → Secrets and variables → Actions):
| Secret | Value |
|---|---|
VITE_SANITY_PROJECT_ID |
Same as .env's VITE_SANITY_PROJECT_ID |
VITE_SANITY_DATASET |
Same as .env's VITE_SANITY_DATASET |
VITE_SANITY_API_VERSION |
Same as .env's VITE_SANITY_API_VERSION |
VITE_SANITY_TOKEN |
Same as .env's VITE_SANITY_TOKEN (blank is fine if unset there) |
DROPLET_HOST |
159.223.145.47 |
DROPLET_USER |
deploy — a dedicated, non-root, key-only user with no sudo |
DROPLET_SSH_KEY |
Private half of the deploy user's dedicated deploy key (no passphrase) |
DROPLET_TARGET_PATH |
/var/www/codeboxx |
On the Droplet (already done for "CodeBoxx Web Claude"):
- nginx installed and enabled, serving
/var/www/codeboxx. Each URL resolves to its own prerendered file (/blog→dist/blog/index.html, perbuild.format: 'directory'inastro.config.mjs), and unmatched paths getdist/404.htmlwith a 404 status. Don't restore the old SPA fallback (try_files $uri /index.html;) — it would serve the homepage shell for every route. - A
deploysystem user owns/var/www/codeboxx, has no sudo access, and accepts SSH only via the dedicated deploy key (password auth disabled). Itsauthorized_keysholds only that key's public half. - No domain/TLS yet — nginx answers on port 80 for any
Host(catch-allserver_name _;). Point a domain's A record at the Droplet and runcertbot --nginxlater to add HTTPS; updateserver_nameaccordingly at that point.
The deploy step runs rsync --delete, so DROPLET_TARGET_PATH should stay
dedicated to this site — anything else living in that directory gets removed to
match dist/.
This is a static build: editing/publishing content in Sanity Studio changes
nothing on the live site by itself — it only takes effect on the next build.
deploy.yml listens for a repository_dispatch event (type
sanity-publish) so a Sanity webhook can trigger that rebuild automatically.
Configure it once at sanity.io/manage → project
zagi8xr3 → API → Webhooks → Create webhook:
| Field | Value |
|---|---|
| Dataset | production |
| URL | https://api.github.com/repos/CodeBoxxTechSchool/CodeBoxx-Claude-Website/dispatches |
| HTTP method | POST |
| Trigger on | Create, Update, Delete |
| Projection | {"event_type": "sanity-publish"} |
| Headers | Authorization: Bearer <a GitHub PAT, scoped to just this repo, "Contents: Read and write">; Accept: application/vnd.github+json; X-GitHub-Api-Version: 2022-11-28 |
Filter/drafts settings can stay at their defaults — webhooks only fire on published changes unless "include drafts" is turned on, which is exactly what we want here. No extra GROQ filter is needed since every document type on this site (posts, landing pages, testimonials, etc.) affects some page's build-time data.
astro.config.mjs output: 'static', @astrojs/react + @astrojs/sitemap
src/
pages/ Astro file-based routes
blog/, fr/blogue/ index.astro, [slug].astro
index.astro, fr/index.astro
HomeIsland.jsx
financing.astro, fr/financement.astro
fully static, no island beyond Layout.astro's TopBar/Footer
ventures.astro, fr/ventures.astro
PitchWidget.jsx is the one island
404.astro "Page not found", on Layout.astro
layouts/
SeoHead.astro shared real, build-time SEO <head> tags — used by every native page
Layout.astro shell for Blog/Financing/Ventures/404 — TopBar/Footer as independent islands
components/
ChromeIsland.jsx Astro-native TopBar/Footer (props-driven, no router/i18next)
HomeIsland.jsx Astro-native homepage — one island, see "Why Home is one big island" above
BlogPostsIsland.jsx Astro-native filter/pagination island for /blog
PitchWidget.jsx Astro-native pitch button + drawer island for /ventures
Logo.jsx, Avatar.jsx brand components with no Bootstrap equivalent, used by both
lib/
sanityContent.js build-time Sanity fetch — every native page
sanity.js browser-side Sanity helpers — pickLocale for the landing pages
cohorts.js build-time Academy start dates, from the portal via the relay
blogSeed.js shared fallback post data, blog pages
homeSeed.js shared fallback CodeBlog-teaser data, home page
portableText.js server-side Portable Text → HTML, blog post pages only
i18nRoutes.js EN/FR path helper (no router dependency)
styles/ main.scss + partials (see Styling above)
public/assets/ images referenced by the pages
This project is a full replacement for the previous boilerplate on main:
cd CodeBoxx-Claude-Website
git rm -r --cached . && rm -rf $(ls -A | grep -v '^\.git$')
# unzip the export archive into this directory
npm install
cp .env.example .env
npm run dev
git add -A
git commit -m "feat: replace boilerplate with CodeBoxx'27 site (Vite + React + SCSS + Sanity)"
git push origin main