next-jssanity-cmsreact-19tailwind-cssbilingual-i18nnonprofiteducationresend-emailtypescriptvercel

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

Samvel Avagyan
Samvel Avagyan
Published on
8 min read
Book a Working Session →
Teach For Armenia homepage showing cinematic hero with parallax background and bilingual navigation

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
Teach For Armenia homepage showing cinematic hero with parallax background and bilingual navigation
Teach For Armenia homepage showing cinematic hero with parallax background and bilingual navigation
Sanity Studio dashboard with document list widgets and internationalized content editing
Sanity Studio dashboard with document list widgets and internationalized content editing
News listing page with category filtering, search, and bilingual article cards
News listing page with category filtering, search, and bilingual article cards
+3

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-motion support
  • 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
Teach For Armenia homepage with cinematic hero section and bilingual content
Teach For Armenia homepage with cinematic hero section and bilingual content

Solution Architecture

Technical Approach

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

MetricValue
Pages shipped16 (bilingual EN/HY)
CMS schemas61 (18 singletons, 10 documents, 23 blocks, 10 objects)
Data layer8,047 lines with static fallbacks
Design system4,400+ lines CSS custom properties (12 token layers)
Content blocks23 composable types
Components167 React components
Email templates6 bilingual (React Email + Resend)
Bot protectionHoneypot + time-token (zero CAPTCHA friction)
TypeScript files25,500+
Total commits142 over ~8 weeks
Security headersX-Frame-Options, X-Content-Type-Options, Referrer-Policy
Image optimizationAVIF/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.ts and cms.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.

Discuss a Similar Project