Next.js 15 + Prisma + MongoDB
Owned the core of a 5-repo EdTech platform.
AI exam builder for coaching institutes with student performance analytics.
Owned the core of a 5-repo EdTech platform.
Built a server for live folder collab - one write path, no race conditions.
Singleton for PDF generation - cold start dropped from ~4s to ~600ms warm.
for drag-and-drop reorder - no write storms, no flap on concurrent drags.
Vision-LLM pipeline that turns scanned PDFs into a searchable question bank - EXIF rotation bug ate the most days.
Eduents helps teachers do four things - upload question PDFs and turn them into a searchable question bank, build question papers and exams from that bank, run those exams online or grade paper OMR sheets, and see how the students did.
I built the main app and the live-collaboration server. My teammate built the Python tools that read PDFs and detect images. We meet at a small set of API rules.
The question bank holds questions pulled from textbooks and content books. From there, the teacher picks the ones they want, drops them into Create Test, and downloads the paper as a PDF - questions on one file, answers on another (when answers are available).
Honest split of the five repos:
ws library - Me.Each app has a different runtime, different deploy target, and different lifetime. One repo would slow everyone down. Separate repos mean I ship the Next.js app to Vercel, the Python tools live on Railway where slow cold starts do not matter, and my teammate can change his backend without breaking my frontend as long as the JSON shape stays the same.
The contract between the apps is just two things - a list of allowed origins (so the browser can call across domains) and an agreed JSON shape for each endpoint.
Two main areas of data, sharing one database. Area 1 is the question bank and folders - users own folders, folders hold questions, other users can be invited as owner, editor, or viewer. Area 2 is the exams - a test holds questions, students take it, each answer is saved.
One unusual choice - a `Student` is **not** a `User`. A student who only takes one OMR exam should not have to sign up. So the exam side has its own Student model with no login link. Cleaner, fewer accounts to manage.
The Question model is shared by four other tables. Prisma asks for unique relation names when this happens, which looks ugly once and then works forever:
model Question {
id String @id @default(auto()) @map("_id") @db.ObjectId
folderQuestions FolderQuestion[] @relation("QuestionToFolderQuestion")
testQuestions TestQuestion[] @relation("QuestionToTestQuestion")
testAnswers TestAnswer[] @relation("QuestionToTestAnswer")
paperHistory PaperHistoryQuestion[] @relation("QuestionToPaperHistoryQuestion")
}questionId for concurrent drags.When a teacher drags question 5 between question 2 and question 3, two things can go wrong - the app could update every row's number below the move (slow), or two users dragging at the same time could fight.
I used a trick called fractional positions. Instead of integer positions (1, 2, 3...), I use floats (1.0, 2.0, 3.0...). To put a question between 2.0 and 3.0, I just save 2.5. No other rows change. To put one between 2.0 and 2.5, save 2.25. And so on.
When two users drag at the same time and pick the same number, I break the tie using the questionId. Boring rule, never fails.
The most-loved feature is click a button, get a clean question paper PDF. Doing this on a server is hard because the browser engine (Chromium) is huge.
Problem 1 - cold start is slow. The first PDF after a long pause takes about 4 seconds because Chromium has to start. After that, about 600 ms. Fix - keep one Chromium running and reuse it. If Chromium dies, the listener clears the saved one so the next call starts a fresh one.
Problem 2 - 'query engine not found' on Vercel. Prisma needs a small binary file to talk to the database. With my custom Prisma setup, that file did not get copied to the deploy bundle. The deploy died. Fix - a build step that copies the file into the bundle, plus a CI check that fails the build if the file is missing. So this bug cannot come back quietly.
let browserPromise: Promise<Browser> | null = null
export async function getBrowser() {
if (!browserPromise) {
browserPromise = puppeteer.launch({
args: chromium.args,
executablePath: await chromium.executablePath(),
headless: chromium.headless,
})
const browser = await browserPromise
browser.on('disconnected', () => { browserPromise = null })
}
return browserPromise
}Math is everywhere in EdTech. I used two tools because no single one does both jobs well - MathJax on the PDF side (slow but pixel-perfect) and KaTeX on the screen side (fast and good enough for editing).
The same formula does not look identical in both. So I wrote a small helper called jaxUtils.ts that fixes a few known differences (font alignment, a white-on-white bug) right before the PDF screenshot. The result is very close, not identical, looks fine to a human.
When two teachers edit the same folder, their changes should show up live. I built a small WebSocket server that does this.
The server is dumb on purpose. It just passes messages around. It does not save anything to the database. All saving is done by normal server actions in the main Next.js app.
Why split it this way? A WebSocket can disconnect mid-message - a server action either fully saves or fails clean. Only one place writes to the database - no two-writers fighting. The cost is one extra HTTP request per change. Worth it for the safety.
The connect rule is strict - if the client does not send folderId, userId, and userName, the server slams the door:
const folderId = url.searchParams.get('folderId')
const userId = url.searchParams.get('userId')
const userName = url.searchParams.get('userName')
if (!folderId || !userId || !userName) {
ws.close(1008, 'Missing required parameters')
return
}middleware.ts does CORS + auth + onboarding in one pass. Skip the layout-redirect-loop trap.One file, eduents/middleware.ts, runs on every request and does three things in one pass:
In eduents/lib/school-test/, my TypeScript code does these steps for each page of a PDF:
/api/omr/checker is one server action that does the whole grading job in one go:
Student record (no login needed).The four side apps call the main app from a different domain. The browser blocks this by default. To allow it, the main app keeps a list of allowed origins in its middleware.
There is no fancy service registry. The list is hand-edited. That sounds primitive but is actually safer - adding a new origin needs a code review.
questionId, plus a unique index on (folder, position).I built Eduents because a teacher I know was spending six hours a week building question papers in Word and grading OMR sheets by hand. Multiply that by ten teachers, then by ten institutes - real time, real money.
If you are hiring, what I want you to take from this:
Open to full-time roles and freelance projects. If you are building something hard - real-time, AI pipelines, complex schemas, anything where the boring layers eat your week - drop a message.