← All notes

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.

The new EMFPostulations form: a step list on the left and one section at a time
The old EMFInscriptions form: tabs and one long grid of fields
The same first step, then and now. Both are online as I write this.

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.

The last step, after 10 p.m. Sending is two visible steps: save the application, then generate the PDF.

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.

Nationality
Hidden, so not required.
front/src/lib/browser-storage.ts
/* 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.

Collapsed, the sidebar becomes a rail of dashes. They swell in a wave under the pointer.

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

On a phone, the steps become a trail at the bottom, under the thumb.

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.

documentation/canton-du-candidat-permis-b.md
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 exists

The 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.

Page one of the PDF, with a candidate who doesn’t exist.

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.