JacinthForge
An e-learning platform connecting instructors with apprentices. I built the frontend end-to-end: a four-step course wizard, a media pipeline, a quiz builder, and a certificate generator that runs entirely in the browser.

Role
Frontend Engineer
Duration
Multi-month
Core stack
Next.js · React · TypeScript
Context
Overview
JacinthForge is an LMS that lets instructors publish courses and apprentices enroll, consume content, take assessments, and receive verifiable certificates. I owned the frontend from UI through API integration, and contributed to product decisions, UX improvements, and production troubleshooting.
The Brief
Build the frontend for a three-role platform — apprentice, instructor, admin. Instructors need to create courses across four dependent steps, with arbitrary nesting inside the curriculum. Every lecture can hold video, text, a document, or a quiz. Every course ends in a certificate that needs to be downloadable, shareable, and verifiable by URL.
Constraints
- Course data is deeply nested: sections contain lectures, lectures contain content of three different types, quizzes contain questions with options, and the whole thing needs to save incrementally
- Users lose work: tabs close, laptops die, someone opens the same course in two tabs
- File uploads need a lecture ID that doesn't exist yet — you upload attachments to a lecture you're still creating
- Certificates need to be downloadable as PDF, shareable as images, and verifiable by ID — with no backend rendering service
01
Course creation as a multi-step wizard
The course editor is four steps that depend on each other: Basic Info → Advanced Info → Curriculum → Publish. Each step hydrates from a Zustand store, edits through its own React Hook Form instance, and writes back on submit.
The interesting problem was structural changes inside the Curriculum step. Adding a section or a lecture needs to persist to the server immediately — otherwise the user closes the tab, comes back, and finds their outline gone. But a section that exists only in the browser has no ID yet, and the file upload inside a lecture needs a lecture ID to attach to.
I solved it with temp IDs. Every new section gets temp-section-{uuid}, every new lecture gets temp-lecture-{uuid}. When the server responds with the real IDs, the store swaps them in and RHF resets. The UI never waits on the network for a structural change; it rolls back on failure instead.
// src/components/dashboard/instructor/create-course/curriculum-step/CurriculumCourseFormWrapper.tsx (excerpt)
const dataForBackend = {
sections: form.getValues().sections.map((section) => ({
...section,
id: section.id?.startsWith("temp-") ? "" : section.id,
lectures: section.lectures.map((lecture) => ({
...lecture,
id: lecture.id?.startsWith("temp-") ? "" : lecture.id,
})),
})),
};
const updatedSections = await handleAutoSaveCurriculum(dataForBackend);
// Swap temp IDs for real ones
if (updatedSections) {
form.setValue(`sections.${newIndex}`, updatedSections[newIndex], {
shouldValidate: true,
});
}- Uploads queue when the parent lecture ID doesn't exist yet, and fire automatically once the ID arrives. The user sees a spinner, not an error.
- A local draft system lives in localStorage under course_draft::{id}, with a 24-hour TTL. On page load, the local draft's timestamp is compared against the server's. Local newer → show a modal. Server newer → silently sync.
- Every step has its own RHF instance and hydration key. Adding a new step is adding a new file, not editing a shared wizard.
02
Certificates rendered in the browser
Certificates needed to be downloadable as PDFs, shareable as images on mobile, and verifiable through a public URL. The backend wasn't set up for image rendering, and I wanted the certificate to be a React component so designers could iterate on it in JSX.
The approach: render the certificate in an off-screen container, wait for every image inside to load and for the browser to paint, capture the DOM with html-to-image, then ship it through jsPDF for PDF export and the Web Share API for mobile sharing.
The hard parts were all timing. Safari captures before fonts and images have rendered unless you wait — I use a double requestAnimationFrame plus a 50ms buffer to guarantee the paint is done. html-to-image doesn't wait for <img> elements, so I query every image inside the container and await their load or error events before capturing. The QR code is generated asynchronously as a data URL, which means the whole capture path is stateful — the image isn't ready until the QR is.
// src/hooks/useCertificate.ts (excerpt)
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTimeout(resolve, 50); // extra buffer for Safari
});
});
});
const dataUrl = await toPng(containerRef.current, {
cacheBust: true,
backgroundColor: "#ffffff",
pixelRatio: 2,
skipFonts: true,
});- One hook — useCertificate — powers the download button, the share sheet, and the public verification page. The abstraction held across all three consumers.
- The certificate is a React component a designer can edit. No backend rendering service, no server round-trip, no format drift between PDF and PNG.
- Every capture path (PNG, PDF, share, preview) goes through the same wait-then-capture pipeline, so behaviour is identical no matter which button the user clicks.
Impact
PageSpeed
96
Frontend performance score
Course creation
4 steps
Down from a single monolithic form
Certificate generation
0 backend calls
Entirely client-side
Lost-work incidents
0
Post draft system launch
Stack
Framework
- Next.js
- React
- TypeScript
Forms
- React Hook Form
- Zod
State
- Zustand
Editor
- TipTap
Uploads
- S3 presigned URLs
- react-dropzone
Certificate
- html-to-image
- jsPDF
- QR code generation
What I'd do differently
The temp ID reconciliation is the part I'd rewrite. It's fragile if the server ever returns entities in a different order — the index-based matching would silently attach the wrong real ID to the wrong temp one. A proper ID mapping returned by the API would be more robust. The certificate pipeline is the part I'm proudest of: it's a real abstraction that solved the same problem three different ways without a single branch inside the hook.
Next case study
justindavid.dev
Design system • Case-study architecture