Building a QTI 3.0 Authoring System with Deno
We built QFlow because the institutions we work with need to edit, validate, and move assessment content between systems.
Most assessment content is written inside an LMS or testing platform. The same system stores it, previews it, and often becomes the only place where anyone can edit it.
Many products support QTI exports, but the standard sits at the edge of the product. It is something the system produces on request, not the format that guides how it stores and handles questions.
This causes trouble during an LMS migration or platform consolidation. Institutions have to move item banks from old tools, preserve accessibility information, and make the questions work in a different delivery system. An exported file is only useful if another system can validate, open, and edit it.
We built QFlow for this work. It reads, edits, validates, and writes QTI during the normal authoring workflow, rather than converting everything at export time. The project is available at QFlowLearn.com.
Why QTI 3.0
QTI has a reputation for being difficult to implement. Version 3.0 still requires careful work, but 1EdTech now provides enough public material to use it as the basis for an authoring system.
The QTI overview and implementation guide explain the format, while the suggested procurement language helps institutions specify what they expect from vendors. Those documents make support for QTI easier to evaluate than a checkbox that simply says “QTI compatible.”
Version 3 also includes accessibility in the assessment model. Its structures can describe support content and delivery behavior, so an authoring tool does not have to bolt that information onto an item later.
We also needed something besides our own code to test against. AMP Up publishes an open-source QTI 3 player and test runner. Its QTI 3 Player/TestRunner repository and the demo at qti.amp-up.io give us an independent implementation for testing the files QFlow produces.
What we needed from an authoring tool
Many established authoring tools were designed around server-side workflows and storage owned by a single platform. Their import support is limited, and exported content does not always survive a move cleanly.
The institutions we work with have item banks from several generations of software. They merge systems, change LMSes, and update accessibility practices. They cannot assume that every question begins in a new editor or stays in one product.
Some institutions now use AI to draft questions. That draft still needs response options, scoring rules, metadata, and accessibility information before it can become an assessment item. The finished item also needs to be stored in a format other systems can read.
QFlow imports QTI 3.0, QTI 2.1 and 2.2, and QTI 1.2 and 1.2.1 because that is what these institutions already have. It stores question revisions in Postgres, validates output locally against vendored QTI 3 XSDs, and exports assessments from the authoring workflow. An imported bank gets the same validation and revision history as content written in QFlow.

Editing a demo assessment in QFlow, with question content, preview, accessibility settings, and export in the same workspace.
Why we chose Deno
We chose Deno mainly to keep the application in one language and one runtime. The authoring interface depends heavily on the same import, validation, and export code used by the backend, so splitting the stack would have created an unnecessary boundary.
- TypeScript support is built in.
- Deno includes the formatter, linter, task runner, test runner, and runtime configuration we need.
- Runtime permissions let us control access to authored content, uploaded files, and external services.
- Local development requires little build configuration.
QFlow uses Fresh to render most pages on the server. Preact islands handle the parts that need to run in the browser, including rich-text editing and interaction builders. Only those interactive components need client-side code.
The rest of the stack includes:
- Deno 2.5+, Fresh 2.x, and Vite for the application shell
- Preact islands and signals for client-side interactions
- ProseKit for rich-text editing
- Postgres and Drizzle for authoring data
- Cloudflare R2 for uploads and portable asset references
xmllintand vendored QTI 3 XSDs for local validation
The authoring interface and the routes for importing, validating, storing, and exporting content all use TypeScript. When we change the item model, we can follow that change through the application without crossing a language or service boundary.
What QFlow supports today
QFlow currently supports single-select, multi-select, ordering, matching, and text-entry items. It keeps a revision history for each question, rewrites imported assets as portable upload references, and builds QTI 3 assessments through the same application authors use to edit them.
Fresh route handlers under routes/ provide the backend. The import and export code lives near the authoring interface and the data model it uses. We can test an item from import through editing and export without coordinating several services.
We can now test the design in a working authoring flow. An author can import an older bank, edit and preview its questions, validate the output, and export it again in QFlow. The code for each step remains in one TypeScript application, so a small team can work on the entire flow.
References
- QFlowLearn: https://qflowlearn.com/
- 1EdTech QTI overview: https://www.1edtech.org/standards/qti
- QTI v3 Best Practices and Implementation Guide: https://www.imsglobal.org/spec/qti/v3p0/impl
- Suggested QTI requirements for procurement and RFPs: https://www.1edtech.org/standards/qti/rfp-procurement-agreements
- AMP Up QTI 3 Player/TestRunner: https://github.com/amp-up-io/qti3-item-player
- AMP Up live demo: https://qti.amp-up.io
- Deno: https://deno.com/
- Fresh: https://fresh.deno.dev/