Skip to main content
cases

Robin — Bilingual Corporate Website & Admin CMS

Bilingual Next.js 16 corporate website with full admin CMS panel for a client. Persian-primary with English, automatic RTL/LTR layout switching, Jalaali calendar, Framer Motion scroll animations, TipTap v3 rich text editor, custom JWT authentication (HMAC-SHA256 + PBKDF2), 8 MongoDB collections, and a hardened VPS deployment behind Cloudflare Full Strict SSL.

View Live
2Locales
8MongoDB Collections
310kPBKDF2 Iterations
63 yearsHSTS max-age
14 daysLog Retention
Daily 2 AMBackup Schedule
Next.js 16React 19TypeScriptTailwind CSS v4Framer Motion 12MongoDBMongooseTipTap v3Winstonnode-cronPM2 + NginxCloudflare

The Problem

Robin is a corporate services company that needed a bilingual public-facing website — Persian primary, English secondary — alongside a private admin CMS where staff could manage all site content without developer involvement. The two parts have different audiences and different requirements: the public site needed animated scroll-driven presentation, correct RTL layout for Persian, and Jalaali calendar display; the admin CMS needed a capable rich text editor, media library, and a content model flexible enough for articles, project showcases, team profiles, and service categories.

The bilingual requirement touched everything. A shared React component tree has to render correctly in both directions. A rich text editor that works in English will misbehave with Persian text if direction isn't set explicitly. The routing has to know which locale is active and apply the correct dir attribute at the layout level without prop-drilling it through every component.

The auth requirement was specific: no third-party JWT library. Custom HMAC-SHA256 signing with PBKDF2 password hashing, HttpOnly cookies, and admin route protection in Next.js middleware. The client wanted full visibility into the auth code — no opaque dependencies.

Bilingual Routing and RTL/LTR Switching

The application has a single shared component tree for both locales. Locale detection uses the Accept-Language header and a cookie preference, with Persian as the default. The Next.js middleware reads the resolved locale and sets a lang cookie that layouts consume via cookies().

The root layout sets lang and dir on the <html> element from the resolved locale. Persian and Arabic get dir="rtl"; English gets dir="ltr". Tailwind's rtl: modifier handles the directional variants in components — padding, margin, flexbox direction, text alignment — without a separate RTL stylesheet.

Jalaali calendar conversion uses a utility that maps Gregorian dates to Solar Hijri (Shamsi) at the display layer. Dates stored in MongoDB are always UTC; conversion happens on read. This keeps the data layer clean and avoids timezone confusion in queries.

Self-hosted variable fonts — Urbanist for English, Peyda for Persian — load via next/font/local with display: swap. The font loader selects the correct family based on dir. This avoids FOUT on the primary locale and eliminates the external network request that Google Fonts would require.

Framer Motion Animations

The services section uses two animation patterns. The first is a viewport-based FadeIn component — a wrapper that accepts direction (up, left, right), delay, and a reduced-motion flag. When prefers-reduced-motion: reduce is detected, the wrapper renders children immediately without any animation. This is not an afterthought; the media query check runs before any animation is scheduled.

The second pattern is CountUp number counters — animated from zero to a target value on scroll entry. The counters display company statistics (years, clients, projects, team size) that appear in the services showcase. For Persian locale, the counter outputs Persian-Indic numerals (۰–۹) by running the incremented integer through a digit-conversion function before rendering. This is a display-layer transformation — the underlying counter value is always a JS number.

Both animation patterns use useInView from Framer Motion to trigger on scroll entry rather than on page load. This means the animations fire when the section enters the viewport, which matches the editorial intent of the services showcase.

TipTap v3 Editor

The editor is TipTap v3 with a standard extension set: StarterKit (headings, lists, blockquote, bold, italic, code), plus explicit image, link, and text-alignment extensions. The admin panel shows a media picker modal alongside the editor — clicking the image button opens a grid of uploaded media, and selecting an item inserts it at the cursor position without leaving the editing context.

HTML sanitization uses sanitize-html with a strict allowlist on the server before persistence and on output before rendering. The client provided the allowlist: standard block and inline elements, no <script>, no on* event attributes, no javascript: href values. The sanitization runs server-side on every save — client-side editing can produce arbitrary HTML, and the sanitizer is the authoritative gate before anything reaches the database or the rendered page.

The editor does not have multiple locale tabs. Unlike Signal Before Noise where articles existed in up to four translations managed simultaneously, Robin's content model is simpler: a single locale per content item. Bilingual support is at the routing and display layer, not the content layer. Staff write Persian content for the Persian site and English content for the English site; they are separate content items in the same collections, not the same item with translation slots.

Admin CMS Panel Architecture

The admin panel covers eight resource types: articles, projects, team members, categories, contact messages, media, backups, and backup configuration. Each has a list view with search and pagination, and a detail view with the TipTap editor or a structured form.

Media upload accepts images, documents, and videos. Files receive UUID filenames on the server before storage — the original filename is preserved in the MongoDB metadata record but never used in the filesystem path. MIME-type validation runs on both the client (file picker accept attribute) and the server (header check against the allowed list). Metadata records track original filename, MIME type, size, upload timestamp, and uploader identity.

The data model uses 8 MongoDB collections: articles, projects, contact_messages, team, categories, media, backups, and backup_config. Zod schemas are defined once and used on both client (form validation) and server (API route validation) — the same schema rejects invalid data at the form level and at the API boundary.

Authentication

Authentication is custom from the primitive level up. No jsonwebtoken, no jose, no auth library. JWT token construction uses Node's built-in crypto module: header and payload are base64url-encoded, concatenated, and signed with HMAC-SHA256 using a secret from environment variables.

Password hashing uses PBKDF2 — also via Node's crypto module — with 310,000 iterations, a random 64-byte salt, and SHA-256 as the hash function. PBKDF2 at this iteration count is deliberately slow: it takes approximately 300–500ms per hash on the server. That is the point. A brute-force attack against a stolen hash database faces 300–500ms per guess instead of microseconds.

Tokens travel in HttpOnly Secure SameSite=Strict cookies. The HttpOnly flag prevents JavaScript access — XSS on the client cannot read the token. SameSite=Strict prevents the token from being sent on cross-origin requests, which eliminates the CSRF attack surface that SameSite=None or SameSite=Lax leave open.

Admin route protection runs in Next.js middleware via a matcher on the /admin path prefix. The middleware reads the token cookie, verifies the HMAC signature and expiry, and redirects unauthenticated requests to the login page. This means protection is enforced before the request reaches any route handler or Server Component — there is no per-route auth check to forget.

Sessions are 7 days. The token carries an exp claim in standard JWT format; the middleware verifies it on every request. Refresh happens transparently: if a valid token is within 24 hours of expiry, the middleware issues a new one with a fresh 7-day window.

Backup System

Daily backups run via node-cron at 2 AM UTC. mongodump writes a BSON archive to a staging directory, which is then compressed into a .tar.gz with the timestamp in the filename. The final archive moves to the backup storage directory.

Manual backup triggers are available via an API endpoint gated behind admin auth. This covers the case where a significant content change is about to happen and staff want a checkpoint before proceeding.

Restore accepts a backup ID (stored backup) or an uploaded archive. Restoring from an uploaded archive supports the server migration case — bring a backup from an old server, restore it on the new one without needing the backup storage directory to exist.

The backup_config collection stores the cron schedule and retention count. Changing the backup frequency is an admin UI operation, not a code change.

Security Headers and Deployment

Security headers are set on all responses via Next.js middleware:

  • Content-Security-Policy — allowlist for scripts, styles, images, fonts, and connect targets
  • HSTSmax-age=1971504000 (63 years), includeSubDomains, preload
  • X-Frame-Options: DENY — prevents framing by any origin
  • X-Content-Type-Options: nosniff — prevents MIME-type sniffing
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy — disables camera, microphone, geolocation for all origins

HSTS at 63 years with the preload flag is a statement of intent: this domain will use HTTPS indefinitely. The preload flag enables submission to browser HSTS preload lists, which means the browser enforces HTTPS before the first connection — no initial unprotected redirect.

The deployment stack is PM2 for process management, Nginx as reverse proxy with rate limiting (10 requests per second per IP on API routes, 30 per second on static assets), and Cloudflare Full Strict SSL with Origin Certificates. Full Strict mode means both legs of the connection are encrypted — client to Cloudflare, and Cloudflare to origin. Flexible mode, where the origin leg is unencrypted, is not used.

Winston structured logging writes to both files and console. File output uses daily rotation with 14-day retention and a 20MB per-file cap. The retention period and size cap were set to match the VPS disk allocation — logging indefinitely on a constrained VPS is a disk exhaustion path.

What Shipped

The platform is live at robinity.com. The public site supports Persian and English with correct RTL/LTR layout in both locales, animated services showcase with Framer Motion, Persian numeral conversion for counters, and Jalaali calendar display. The admin CMS provides full CRUD for all content types through the TipTap editor without developer involvement. Authentication is custom HMAC-SHA256 with PBKDF2, HttpOnly cookies, and middleware-level route protection.

What I'd Change

The language switching on the public site is a hard navigation — clicking the language toggle causes a full page reload to the equivalent URL in the other locale. For a corporate site with modest content, this is acceptable. For a content-heavy site with long pages, it would be disruptive. The correct approach is to share layout state across locale navigations using Next.js's useRouter and router.replace() with a locale segment swap, which preserves scroll position and avoids re-mounting unchanged layout sections. I'd implement this before adding a third locale.

The Zod schemas on the client use safeParse for form validation and provide field-level errors. The admin UI currently shows one error at a time — whichever field is touched first. Multi-field simultaneous error display would improve the editorial experience when filling out a form cold. This is a UI-level change (passing the full error.flatten() result to the form state) rather than a schema change.