Rewriting EMFInscriptions as EMFPostulations
How a school’s public application form was rebuilt, and what the shared hosting, the old database and a very odd canton taught me.
- Role
- Lead developer, 162 of 180 commits
- Since
- August 2026
- Built with
- React, Node.js, Prisma, MySQL
- Status
- In production
This is how future apprentices apply to the École des Métiers de Fribourg, where I’m an apprentice myself. The old form, EMFInscriptions, was built in 2018 and 2019 in PHP, then extended by several people until it was hard to test and expensive to change. EMFPostulations replaces it.
The job sounds small: collect an application, produce a PDF, email it. The candidate prints it, signs it and sends it to the secretariat with the annexes.


What I pushed back on
The brief asked for MUI and a plain white design. I proposed shadcn/ui, Tailwind and my own library, Unlumen UI, because a form this long lives or dies on its inputs and I’d already polished those. And a dark mode, because white is beautiful, but after 10 p.m. it hurts.
Next.js was out. The school only has shared hosting, no server of its own, so React and Vite build static files, and a separate Node API runs from the host’s panel. The hosting ended up deciding more of the architecture than I did.
A form that doesn’t forget
Nine steps is long. Every answer is kept in the browser as you type, so closing the tab costs nothing, and the draft erases itself after 30 days without a visit.
A hidden field is never required, but its value stays. Say you have a residence permit, type it in, then change your mind and change it back: what you typed is still there. Try it.
/* the draft is erased after 30 days without a visit */
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000
export function expireApplicationDraft() {
try {
const touched = Number(window.localStorage.getItem(TOUCHED_KEY))
if (touched && Date.now() - touched > RETENTION_MS) clearApplicationDraft()
} catch {
// Storage blocked: there is no draft to expire.
}
}The rules for what appears when came straight from the old form’s JavaScript, so the new form asks for the same information and the same annexes. The annexes you see depend on your answers.
Nine steps need to stay in view without taking room from the form. Each dash is a step, the current one is in color, and the name only appears under your pointer. A small copy, to run your hand over.
Step 3 of 9: School
Canton: Permit B
The tidiest model I considered had one catalog table for every dropdown: cantons, permits, titles, all in the same place. Then I noticed what a foreign key really checks.
t_valeur_catalogue
id type_catalogue code label
10 canton FR Fribourg
20 permis_sejour B Permis B
t_candidat.FK_canton = 20 -- the database accepts it: row 20 existsThe candidate’s canton is now “Permit B.” The closed menus prevent it in normal use, but a request sent straight to the API, a bug or an import goes around the menu. So every list got its own table, and translations moved to their own tables with stable codes. More tables, and an error the database refuses to store.
Saving comes first
Once someone presses send, nothing may lose their application. Saving and generating the PDF are independent: if the PDF or the email fails, the application is kept and the step is replayed with the same reference, without creating a second file.
Puppeteer would have made the layout easy, but Chromium can’t be installed on shared hosting. So the PDF is drawn with PDFKit, line by line, in French, German and English. It is never stored: it is regenerated on demand, and the database only keeps when it was generated and when it was sent.
The browser is never trusted either. The server checks the application period again when you send, verifies the Cloudflare Turnstile token, and validates everything the form already validated.
One repository
Front-end, API and documentation sit side by side, so the next developer finds everything in one place.
The interface future apprentices see, translated through i18n.
Routes and validation, with saving kept separate from generating the PDF. Admins sign in through Microsoft OpenID.
analyse · conception · API · prisma · traductions · OpenID · tests technologiques · structure · starting the project · déploiement · journalisation · secrets
GitLab checks every push: lint, types, tests, build. The test branch deploys by itself; production only goes out when someone tags a version.
What I learned
Read the old code before writing the new one. The rules that matter were in places nobody had documented, like the conditions buried in the old form’s JavaScript.
Constraints are design input. The hosting chose Vite, separate deployments and PDFKit, and each of those choices is simpler to hand over because of it.