Skip to content
Huy Vo
Work

Tanca · AI recruitment · marketing site, candidate app, back office

Career GPT

An AI recruitment platform: a three.js marketing site, a candidate app with AI interview practice, and an 18-screen back office.

Visit careergpt.vn (opens in a new tab)

Screen recording

Role
Front-end lead (about 95% of the front-end commits)
Year
2026
Platforms
Web
Stack
  • Next.js 16
  • React 19
  • three.js
  • GSAP
  • Tailwind v4
  • Playwright

Career GPT matches candidates and employers with AI screening and interview practice. It needed a marketing site that felt like a product launch, an app candidates come back to, and a back office ops could run from day one.

My part. 96% of the web repo and 95% of the back-office repo. The NestJS API it consumes was built by the backend team.

Outcomes

  • 18

    back-office screens

    Source: git

  • 4

    3D approaches spiked before shipping

    Source: git

  • 2

    locales with SEO + GEO (llms.txt, JSON-LD)

    Source: code

What I built

A 3D hero that earns its weight

I spiked React Three Fiber, plain three.js, Spline and a video loop side by side, and shipped plain three.js: a career-route scene, plus six GSAP motion primitives the landing composes from, all respecting reduced motion.

Candidate app

Onboarding, a CV builder, a six-axis skills route, AI interview practice graded under a 30-second limit, jobs search and apply, and a single next-step engine that always knows what to suggest.

The candidate app on three phones: onboarding step 2 choosing a target role from suggested searches, an AI mock interview question with a typed STAR-style answer ready to send, and the jobs list with search, filters, salary ranges and an “applied” badge.

Back office and quality

Faceted filters, URL-state tables and an AI quiz generator; an MSW mock back office; Vitest and Playwright with an axe baseline; CSP with nonces.

The back office’s Candidates table with the status facet open: a searchable popover with three options, one selected, the filter chip shown in the toolbar, and the ten matching candidates with job status, current title, GPS score bars, AI interview results and top skills.

Architecture

CAREER-WEB · NEXT.JS 16CAREER-ADMIN · NEXT.JS 16VisitorsOps teamMarketing sitethree.js · GSAPCandidate appAI interview prepBlog CMSISR blogSchool portalpartner tenantsBack office18 screensMock APIMSW · dev onlyCareer APINestJS 11MySQLSIGN UPRESTREST
The marketing site and the candidate app share one Next.js app; the ops team works in a separate Next.js back office. Both call the team's NestJS API, backed by MySQL. The blog CMS and the partner school portal are the team's.
  • Components:
    • Visitors
    • Ops team
    • career-web · Next.js 16: Marketing site (three.js · GSAP), built by me
    • career-web · Next.js 16: Candidate app (AI interview prep), built by me
    • career-web · Next.js 16: Blog CMS (ISR blog)
    • career-web · Next.js 16: School portal (partner tenants)
    • career-admin · Next.js 16: Back office (18 screens), built by me
    • career-admin · Next.js 16: Mock API (MSW · dev only), built by me
    • Career API (NestJS 11)
    • MySQL
  • Connections:
    • Visitors to Marketing site
    • Marketing site to Candidate app: sign up
    • Candidate app to Career API: REST
    • Ops team to Back office
    • Back office to Career API: REST
    • Back office to Mock API
    • Career API to MySQL

Key decisions

  1. Plain three.js for the hero

    I measured one scene four ways and shipped plain three.js: 187 KB gzip against 247 KB for R3F and 602 KB for Spline, behind a capability gate.

    Problem, choice and trade-off: Plain three.js for the hero
    Problem
    The landing was to get a 3D hero, but its mobile LCP element was the lead paragraph and the page already shipped about 182 KB of JS.
    Choice
    I built one test scene four ways on spike pages (React Three Fiber, plain three.js, Spline, a video loop) and measured each. I shipped plain three.js behind a one-time gate (reduced motion, Save-Data, WebGL2 without a major performance caveat, no software renderer, 4+ cores and 4+ GB) over a server-rendered poster in a fixed frame.
    Why
    Plain three.js drew the same scene for 187 KB gzip against 247 KB for R3F and 602 KB for Spline, with a first frame 2 to 4 times faster. LCP and CLS did not move, because nothing 3D loads before the gate.
    Trade-off
    I own the lifecycle by hand (resize, dispose, context loss, FPS guard, about 150 lines). A review later found the canvas leaking across page changes until I created it outside the React tree.
  2. Fonts cut by subset

    Self-hosted latin and vietnamese subsets with only first-screen preloads: home fonts fell to 183 KB and mobile Lighthouse LCP from 4.75 to 4.02 s.

    Problem, choice and trade-off: Fonts cut by subset
    Problem
    Fonts were the heaviest part of the home page: 302 KB and 14 preloaded files, all ahead of the LCP text.
    Choice
    I self-hosted Be Vietnam Pro and Inter as the exact Google latin and vietnamese files (byte-identical, SHA-256 recorded), dropped latin-ext, shared one font module across the three root layouts, and preloaded only the first-screen weights.
    Why
    The waste was not the number of weights. latin-ext overlaps letters like ă, đ, ơ and ư, so Vietnamese pages downloaded both subsets, and Turbopack merged the font CSS of all three layouts, so the app preloaded the admin's fonts. Home fonts fell to 183 KB and mobile Lighthouse LCP from 4.75 to 4.02 s.
    Trade-off
    I tried going down to 4 preloads, but CLS rose from 0 to 0.043, so I kept 8.
  3. Line breaks checked in CI

    A CI check joins Vietnamese compound words in headings with non-breaking spaces; its first run joined 1,335 headings and labels.

    Problem, choice and trade-off: Line breaks checked in CI
    Problem
    Vietnamese headings broke in the middle of compound words ('hạn / mức', 'mô / tả'), and hand fixes across more than a thousand strings would not survive the next copy change.
    Choice
    I wrote a vi-nbsp script that joins 2 and 3 syllable compound words in headings and short labels with U+00A0, using a 54,005-entry dictionary filtered from an Apache-2.0 Vietnamese word list plus a site glossary and a deny list. It is idempotent, skips ICU plurals, placeholders and tags, and runs as check:vi-nbsp in GitLab CI.
    Why
    As a check, every new Vietnamese string meets the same rule without anyone remembering to look at line breaks. The first run joined 1,335 headings and labels.
    Trade-off
    A dictionary can join words that should split in context, so the tool has an --ambiguous review mode and a deny list and stays out of body text. The GSAP heading splitter also had to stop collapsing whitespace to keep the NBSPs.

Credit. The API, the partner school portal and the blog CMS are the team's. The repos were scaffolded by the backend team.

Next case

Skillify

E-learning · iOS, Android, web, CMS