Teach For Armenia (Next.js 16, Sanity CMS, Bilingual)

- Published on

- Duration
- 2 Months
- Role
- Full-Stack Developer
- Technology
- Next.js 16, React 19, Sanity v5, Tailwind CSS 4
- Outcome
- 16 pages, 2 locales, 61 CMS schemas, zero hardcoded content



Staging preview — The link above points to a staging environment (
tfa.netarmenia.com). Production DNS migration to the final domain is pending client-side configuration.
Running a national education nonprofit across two languages means every form submission, every newsletter signup, and every page of content needs to work flawlessly in both English and Armenian — with text that renders at different optical sizes, a CMS that non-technical staff can update without developer intervention, and a codebase that doesn't collapse under 8,000 lines of data normalization.
Project Overview
Teach For Armenia is a national service nonprofit that recruits and trains university graduates to teach in under-resourced communities across Armenia. Their previous website was a static site with hardcoded content, no CMS, no form handling, and no bilingual support.
I built the complete platform from scratch as the sole developer:
- 16 bilingual pages with nested tab routing (Program, People, Partners) and CMS-driven content
- Sanity v5 CMS with 18 singleton page types, 10 document collections, and 23 content block types — all with field-level internationalization
- Transactional email pipeline via Resend with React Email templates for application confirmations, contact receipts, and newsletter welcomes — in both English and Armenian
- Cinematic hero system with 7-layer parallax depth, canvas particle fields, gradient meshes, and full
prefers-reduced-motionsupport - Server Actions for application and contact forms with Zod validation, honeypot + time-token bot detection, and non-blocking email delivery
- Dynamic sitemap with bilingual alternates, SEO metadata from CMS, and JSON-LD structured data

Solution Architecture
CMS-First Architecture with Graceful Fallbacks
Every page resolves content through a Sanity adapter layer that handles i18n, asset resolution, and static fallbacks — so the site works even when the CMS is empty.
The architecture follows a strict separation: pages never call the CMS directly. Instead, an 8,000-line data layer (lib/data.ts) normalizes CMS responses, merges with static fallbacks, and exposes typed getter functions. This lets the site render fully even with an empty Sanity dataset — critical during initial content migration.
The CMS adapter uses a Proxy-based router that dispatches all calls to the Sanity backend. Switching CMS providers (the project was migrated from Payload CMS mid-development) requires changing a single import — the rest of the application is decoupled.
Field-level i18n uses Sanity's internationalizedArray plugin. A GROQ helper function resolves locale-specific values with English fallback at query time, avoiding client-side locale switching:
function loc(field: string, locale: string): string {
return `coalesce(${field}[language == "${locale}"][0].value,
${field}[language == "en"][0].value)`;
}
The resolveSingleton function is the backbone of the data layer. It walks an entire Sanity document recursively, resolves i18n arrays to plain strings, extracts image/file asset references, then batch-fetches all asset URLs in a single GROQ query to avoid N+1 waterfalls:
async function resolveSingleton(
doc: Record<string, any> | null, locale: string
): Promise<Record<string, any> | null> {
if (!doc) return null;
const assetRefs: Array<{ path: string[]; ref: string; kind: "image" | "file"; alt?: string; crop?: SanityCrop }> = [];
function resolveValue(val: any, path: string[] = []): any {
if (Array.isArray(val) && val[0]?.value && ("language" in val[0])) {
const localized = val.find(i => stegaClean(i.language) === locale);
return localized?.value ?? val.find(i => stegaClean(i.language) === "en")?.value ?? "";
}
if (val?._type === "image" && val.asset?._ref) {
assetRefs.push({ path, ref: val.asset._ref, kind: "image", alt: val.alt });
return { url: "", alt: val.alt || "" };
}
// ... recursive object/array traversal
}
const resolved = resolveValue(doc);
// Batch-fetch all asset URLs in one query — no N+1
const { data: assets } = await sanityFetch({
query: `*[_id in $refs]{ _id, url }`,
params: { refs: assetRefs.map(r => r.ref) },
});
// ... wire URLs back into resolved tree
return resolved;
}
Challenges & Solutions
Challenge 1: Armenian Typography at Different Optical Sizes
Armenian script (Noto Sans Armenian) renders approximately 7% larger than Latin text (Lato) at the same font size. Headlines overflow containers, buttons wrap unpredictably, and line heights misalign across locales.
Solution: A 12-layer design system implemented in 4,400 lines of CSS custom properties applies per-context optical scaling via :lang(hy) selectors. Display text scales to 0.93x, body to 0.96x, and buttons get dedicated word-break rules. The hero-headline class applies Armenian-specific letter-spacing for large display text.
:lang(hy) .hero-headline {
font-size: 0.93em;
letter-spacing: -0.01em;
word-break: keep-all;
}
The result: identical visual rhythm across both languages, without JavaScript runtime cost.
Challenge 2: Bot Protection Without CAPTCHA
The application and contact forms needed spam protection, but adding a CAPTCHA would create friction for applicants — especially on mobile in Armenia where page loads are already slower. A false positive means losing a potential teacher.
Solution: Two-layer passive detection: a honeypot field (invisible to users, irresistible to bots) plus a time-token that rejects submissions faster than 1.5 seconds. Both return intentionally vague error messages that don't reveal the detection method:
// Honeypot — hidden field that only bots fill
if (rawData.website && rawData.website.trim().length > 0) {
await new Promise((r) => setTimeout(r, 1500));
return { success: false, error: messages.application.verificationBlockedMessage,
outcome: "bot_suspect", detectionMethod: "honeypot", retryable: false };
}
// Time-token — reject inhumanly fast submissions
if (rawData._ts) {
const elapsed = Date.now() - parseInt(rawData._ts, 10);
if (!isNaN(elapsed) && elapsed < MIN_ELAPSED_MS) {
return { success: false,
error: messages.application.verificationSoftFailMessage,
outcome: "bot_suspect", detectionMethod: "timestamp", retryable: true };
}
}
The honeypot adds a deliberate 1.5s delay before responding (mimicking real processing time) so bots can't distinguish rejection from success by response timing.
Challenge 3: Non-Blocking Email After Form Submission
Users need instant feedback when they submit a form, but the email pipeline (render React Email template → send via Resend API) adds 1–3 seconds. Making the user wait for email delivery defeats the purpose of a responsive form.
Solution: CMS storage is the success gate. Once the application is persisted in Sanity, the response fires immediately. Email delivery runs via Promise.allSettled — fire-and-forget — sending both the admin notification and the applicant confirmation in parallel without blocking the response:
// CMS storage succeeds → user gets instant "success"
await cmsCreateApplication({ ...parsed.data, referenceId });
// Emails fire in parallel, never block the response
Promise.allSettled([
renderEmailHtml(ApplicationAdminEmail({ referenceId, ...data }))
.then(html => sendEmail({ to: config.adminTo, subject: `New Application: ${name}`, html })),
renderEmailHtml(ApplicationConfirmationEmail({ firstName, referenceId, locale }))
.then(html => sendEmail({ to: email, subject: `Application Received — ${referenceId}`, html })),
]).catch(err => console.error("[Application] Email delivery failed:", err));
return { success: true, referenceId };
Key Features
Cinematic Hero with 7-Layer Depth — The homepage hero composites gradient meshes, parallax background images, canvas particle fields, scrim overlays, and animated text entrances into a cinematic first impression. Three variants (minimal, standard, cinematic) adapt to different page contexts. A useReducedMotion hook disables all animation layers for users who prefer it — reducing the hero to a clean static image with text.
23-Type Block Content System — News articles and page sections use a composable block renderer that maps Sanity Portable Text block types (rich text, stats grids, hero images, pull quotes, comparison tables, timelines, partner logo grids, and more) to React components. CSS-driven contextual spacing via block-type classes creates intelligent vertical rhythm without manual margin tuning.
Nested Tab Routing with Middleware — The Program, People, and Partners pages use URL-based tab navigation (/en/program/training, /en/people/alumni). Middleware intercepts legacy ?tab= query parameters and redirects to canonical nested routes with 308 status codes. Each tab loads independently via Next.js App Router parallel routes.
Newsletter Service with Adapter Pattern — Newsletter subscriptions flow through an adapter interface that switches between Resend (production) and console logging (development) based on environment. Successful subscriptions trigger three non-blocking side effects: Resend contact creation, Sanity document storage for data resilience, and a bilingual welcome email with proper List-Unsubscribe headers.
Dynamic SEO Pipeline — Every page's metadata (title, description, Open Graph images, Google site verification) is CMS-driven via Sanity singleton settings. The sitemap generates bilingual alternates for all 16 pages plus dynamically fetched news article slugs. JSON-LD structured data is generated per-article for news posts.
Results & Impact
| Metric | Value |
|---|---|
| Pages shipped | 16 (bilingual EN/HY) |
| CMS schemas | 61 (18 singletons, 10 documents, 23 blocks, 10 objects) |
| Data layer | 8,047 lines with static fallbacks |
| Design system | 4,400+ lines CSS custom properties (12 token layers) |
| Content blocks | 23 composable types |
| Components | 167 React components |
| Email templates | 6 bilingual (React Email + Resend) |
| Bot protection | Honeypot + time-token (zero CAPTCHA friction) |
| TypeScript files | 25,500+ |
| Total commits | 142 over ~8 weeks |
| Security headers | X-Frame-Options, X-Content-Type-Options, Referrer-Policy |
| Image optimization | AVIF/WebP, 30-day cache, Sanity CDN crop/hotspot |
The entire site runs with zero hardcoded user-facing content. Non-technical staff can update every headline, metric, team bio, and navigation link through Sanity Studio — in both languages — without touching code.
Reflection
The adapter pattern pays for itself during CMS migrations. Swapping from Payload CMS to Sanity mid-project touched exactly two files (
cms-sanity.tsandcms.ts). The 8,000-line data layer, all 16 pages, and every component continued working unchanged. Abstracting the CMS behind an interface isn't over-engineering — it's insurance against decisions that haven't been made yet.Treating bot protection as UX, not security theatre. Honeypot + time-token catches automated submissions without a single CAPTCHA click. The key insight: bots reveal themselves by what they do (fill hidden fields, submit in milliseconds), not by solving puzzles. Making the delay response look identical to a real server processing time prevents bots from adapting.
Non-blocking side effects change the perceived speed of everything. Once I adopted the pattern of "persist the critical thing, fire-and-forget the notifications," form submissions went from 2–4 seconds to under 500ms perceived latency. The user doesn't care that the email is still rendering — they care that their application was received.