Four Recipes from Building a Quiz Engine

Abstract grid of calm slate tiles with four raised shapes in a row. A thin beam of blue light runs across the floor and makes a hairline crack glow in one of them.

I recently added a quiz section to this site: certification practice exams you can take in the browser, with a shareable result at the end. Most of the work was ordinary. A few parts were not, and none of them had anything to do with quizzes.

They were general problems that any project can hit: drawing text into an image on AWS Lambda, writing a Nuxt module that compiles your own content files and still hot-reloads in dev, building a reorderable list that works without a mouse, and stopping a cached page from coming back when it should not.

They also shared the trait that prompted this post: every one of them failed quietly. The image route returned a 200 with an unreadable picture. The file watcher never fired, and nothing complained. The list animated twice, and only on a real drag. In each case the obvious approach looked like it worked.

Each recipe ends with a way to prove the fix works, because in every case my first success signal was lying. What exposed each one was breaking the thing on purpose and checking whether the signal noticed.

Render text into images on AWS Lambda with custom fonts#

The quiz result page has a share link, and a share link needs an Open Graph image: the 1200×630 picture that LinkedIn or Slack shows when someone pastes the link. Mine shows the score as a dial and the quiz title beside it. The score comes from the URL, so the image has to be drawn per request.

The usual pipeline builds an SVG string and rasterizes it with sharp. Under the hood, sharp uses libvips, libvips uses pango to lay out text, and pango uses fontconfig to find fonts. On a laptop this works the first time, because your laptop has fonts.

The trap#

A Lambda runtime image ships with no fonts at all. When fontconfig cannot find the face you asked for, pango does not throw. It draws .notdef glyphs, the empty boxes people call tofu, and sharp returns a perfectly valid PNG of those boxes. Your handler sends it with a 200.

That is bad on its own, and caching makes it worse. A share image for a given URL never changes, so you want a long cache lifetime, often a year with immutable. One bad render then lands in the CDN and stays there. You cannot withdraw it, and the social platforms keep their own copy too.

I first tried the fixes that issues and blog posts suggest. All three failed:

  • Setting FONTCONFIG_PATH inside the request handler. It had no effect, for reasons the next section explains.
  • Shipping the site's existing .woff2 web font. fontconfig on Amazon Linux 2023 does not index a lone .woff2 file. In a controlled test it drew real text in 0 of 24 renders.
  • Passing the font file to sharp through its fontfile option. That option does not choose the face. The fontconfig directory does.

For a while, my measurements lied too. One check compared the render against one made with a bogus font family, on the theory that tofu would look the same in both. It reported "glyphs drawn" for an image that was tofu from edge to edge, because fontconfig quietly substitutes some face for an unknown family. Most of the intermittent failure rate I chased for days was noise from that check. Once I validated the check against a known-broken setup, the result became simple and repeatable.

The recipe#

Four facts drive the fix:

  1. fontconfig reads FONTCONFIG_PATH once per process, when libvips starts. libvips starts at the first import('sharp'). Anything you set after that point is ignored for the life of the container.
  2. fontconfig indexes TrueType and OpenType files reliably. It does not index a lone .woff2.
  3. If the directory holds several faces of one family, fontconfig can pick a different face on different containers. The output changes even though nothing failed.
  4. /tmp is the only writable place. It survives warm invocations but disappears with the container, so staging has to run on every cold start.

At startup, stage exactly the static TrueType faces you draw with into /tmp: one file per weight, nothing else. Write a minimal fonts.conf that names only that directory. Then set the environment variables before sharp loads.

server/og/fonts.ts
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

const FONT_DIR = '/tmp/og/fonts';
const CONF_DIR = '/tmp/og/fontconfig';
const CACHE_DIR = '/tmp/og/fontconfig-cache';

/** One static TrueType face per weight you draw with, and nothing else. */
export const FACES = {
    400: join(FONT_DIR, 'inter-400.ttf'),
    700: join(FONT_DIR, 'inter-700.ttf')
} as const;

type ReadAsset = (name: string) => Promise<Uint8Array | null>;

async function stage(read: ReadAsset): Promise<void> {
    await mkdir(FONT_DIR, { recursive: true });
    await mkdir(CONF_DIR, { recursive: true });
    // fontconfig does not create its cache folder. Without it, it logs an error and rescans on every start.
    await mkdir(CACHE_DIR, { recursive: true });

    for (const [weight, path] of Object.entries(FACES)) {
        const bytes = await read(`inter-${weight}.ttf`);
        if (!bytes) {
            throw new Error(`Font inter-${weight}.ttf is missing from the server assets`);
        }
        await writeFile(path, bytes);
    }

    await writeFile(join(CONF_DIR, 'fonts.conf'), `<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">
<fontconfig>
  <dir>${FONT_DIR}</dir>
  <cachedir>${CACHE_DIR}</cachedir>
</fontconfig>
`);

    process.env.FONTCONFIG_PATH = CONF_DIR;
    process.env.XDG_CACHE_HOME = '/tmp';
}

let staging: Promise<void> | undefined;

/** Starts staging once per process. */
export function startFontStaging(read: ReadAsset): Promise<void> {
    staging ??= stage(read);
    return staging;
}

/** Resolves when staging has finished, whether it worked or not. */
export function fontsStaged(): Promise<void> {
    return (staging ?? Promise.resolve()).catch(() => undefined);
}

Put the two .ttf files in server/assets/fonts/. Nitro bundles that folder with the server and exposes it as the assets:server storage, so the files travel with the Lambda bundle and need no network fetch. A Nitro plugin starts the staging when the server boots:

server/plugins/og-fonts.ts
import { startFontStaging } from '../og/fonts';

export default defineNitroPlugin(() => {
    startFontStaging(name => useStorage('assets:server').getItemRaw<Uint8Array>(`fonts/${name}`))
        .catch(error => console.error('[og] font staging failed:', error));
});

The race you will not see on your laptop#

This part took the longest to find. Nitro calls an async plugin but does not await it. On a cold start, the first request can reach your route while the plugin is still writing files. If the route imports sharp at that moment, libvips starts, fontconfig reads an empty directory, and it keeps that empty view until the container dies.

Sequence diagram: an unawaited startup plugin is still writing fonts when the image route imports sharp, so fontconfig reads an empty directory and the route returns tofu with a 200. In the fixed path the route awaits staging first and gets real glyphs.The cold-start race between font staging and the first sharp import

Make the renderer wait on the same staging promise, and import sharp lazily, never at the top of the module:

server/og/render.ts
import type SharpConstructor from 'sharp';
import { FACES, fontsStaged } from './fonts';

type Sharp = typeof SharpConstructor;

let sharpModule: Promise<Sharp> | undefined;

/** Imported on first use and only after staging, because libvips reads its font config once. */
function loadSharp(): Promise<Sharp> {
    sharpModule ??= fontsStaged().then(() => import('sharp')).then(module => module.default);
    return sharpModule;
}

Prove the font really resolved#

You need a check that fontconfig's substitution cannot fool. Text width works. In a proportional face such as Inter, mmm is about four times wider than iii. In .notdef boxes, every glyph has the same advance, so the ratio is exactly 1. Measure both strings with pango through sharp's text input, and compare.

server/og/render.ts
type Weight = keyof typeof FACES;

function escapeXml(text: string): string {
    return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

/** Rendered width of a run of text, in pixels, as pango lays it out. */
async function measure(sharp: Sharp, text: string, size: number, weight: Weight): Promise<number> {
    const { width } = await sharp({
        text: {
            text: `<span font_family="Inter" size="${size}pt" weight="${weight}">${escapeXml(text)}</span>`,
            font: 'Inter',
            fontfile: FACES[weight],
            dpi: 72,
            rgba: true
        }
    }).metadata();
    return width ?? 0;
}

/** Real type gives about 4. Tofu gives exactly 1. */
const MIN_RATIO = 2;

let typeCheck: Promise<boolean> | undefined;

/** Decided once per process and kept, because the answer cannot change until the container dies. */
function drawsRealType(sharp: Sharp): Promise<boolean> {
    typeCheck ??= (async () => {
        const narrow = await measure(sharp, 'iii', 100, 700);
        const wide = await measure(sharp, 'mmm', 100, 700);
        return narrow > 0 && wide / narrow >= MIN_RATIO;
    })().catch(() => false);
    return typeCheck;
}

Run this check before any layout code runs, because tofu breaks layout as well as glyphs. Because .notdef advances are small and uniform, every candidate line "fits", so a long title lands on one line at the largest size and runs off the canvas. If your layout code measures text to wrap or shrink it, those measurements are garbage until the check passes.

Then validate the check itself, as I should have from the start: run it once with the fonts removed and confirm that it says no.

Fail closed, and cache only what is right#

The route now has a clear contract. It caches an image for a year only when the fonts passed the check, the layout fit, and the rasterizer did not throw. Every other outcome serves a static fallback image with Cache-Control: no-store, so nothing bad reaches the CDN.

Flowchart: an image request waits for fonts, checks that the mmm to iii width ratio is at least 2, measures and lays out, checks the layout fits, and rasterizes. Only that path serves an immutably cached image. Any failed check or thrown error serves a fallback image with no-store.The route caches only what it can prove is right

server/routes/og.get.ts
import { renderCard } from '../og/render';

export default defineEventHandler(async (event) => {
    const card = await renderCard(getQuery(event));
    if (card.ok) {
        setResponseHeaders(event, {
            'content-type': 'image/png',
            'cache-control': 'public, max-age=31536000, immutable'
        });
        return card.png;
    }

    console.error(`[og] served the fallback image: ${card.reason}`);
    const fallback = await useStorage('assets:server').getItemRaw<Uint8Array>('og-fallback.png');
    setResponseHeaders(event, { 'content-type': 'image/png', 'cache-control': 'no-store' });
    return fallback;
});

Inside renderCard, call loadSharp(), then drawsRealType(), then your layout, and wrap the whole thing in a try that returns { ok: false, reason: 'error' }. Do not retry inside the process. The font state is fixed when libvips starts, so a second attempt draws the same thing. Let the next container try instead.

Keep the fallback image in server/assets/ as well. On many hosts, Amplify included, the static files in public/ live on a different tier from the server function, and the function cannot read them.

Reproduce Lambda on your laptop#

Do not tune text layout on a Mac. pango on macOS and pango on Amazon Linux disagree on metrics. In my case the string 100% measured 228px locally and 207px on Lambda, about 10% apart, which changed which font size my layout picked. A layout tuned on the Mac would have been wrong in production.

Instead, build an image that matches the runtime. Build on Linux x64 so npm installs the same sharp binary as your deploy, then run the server on the AWS Lambda base image, which has no fonts:

Dockerfile
FROM --platform=linux/amd64 node:24-bookworm AS build
WORKDIR /app
COPY . .
RUN npm ci && npm run build

FROM --platform=linux/amd64 public.ecr.aws/lambda/nodejs:24
COPY --from=build /app/.output /app
WORKDIR /app
ENV PORT=3000
EXPOSE 3000
ENTRYPOINT ["node", "server/index.mjs"]

This assumes Nitro's default node-server output in .output/. If you deploy with a preset such as aws-amplify, copy that preset's output folder and start its server entry instead.

With the image built, run three checks:

  1. Render a fixed set of stress inputs (the longest title, accented letters, the widest score) and pin each output's SHA-256 hash. A later change that alters a pixel shows up as a hash change.
  2. Render the same inputs on several fresh containers. Every hash must match across containers, and the first render on each one must be a cold start. This catches the race above.
  3. Build a second image with the font files deleted. Every render must fail closed and serve the fallback with no-store. This is the negative control, and it is what tells you the check works.
Bash
docker build -t og-runtime .
for port in 3001 3002 3003 3004; do
    id=$(docker run -d -p "$port:3000" og-runtime)
    sleep 3
    curl -s "http://localhost:$port/og?r=sample" | shasum -a 256
    docker rm -f "$id" > /dev/null
done

Four identical hashes mean the output is stable across cold containers. Then check the image by eye once, because a stable hash of a broken image is still broken.

The harness also found something I had not looked for: my Amplify deploy settings asked for a Node 22 function while the project, CI, and the harness all ran Node 24. Without the harness, that mismatch would have stayed hidden until something behaved differently in production.

A Nuxt module that compiles content at build time and hot-reloads in dev#

The quizzes are authored as Markdown files in a top-level quizzes/ folder, next to content/. I did not want a Markdown parser or a syntax highlighter in the browser, so a local Nuxt module compiles each file to HTML and JSON at build time. The browser downloads only the compiled result.

The general problem is common: you have source files that are not Nuxt Content, and you want them compiled, validated, served, and live-reloaded while you edit. Nuxt Kit has an API for each of those steps, and the traps are in how they connect.

Compile in setup, with a different failure rule per mode#

A module's setup runs in three situations, and each one wants different behavior when a source file is invalid:

  • In a production build, throw. A bad file should fail CI, not vanish from the site.
  • In dev, log the error and keep the server running, so you can fix the file and see the result.
  • Under nuxt prepare, skip the compile. Nuxt runs prepare on postinstall and before type checking, so a throw there would make one broken file break npm ci.

Make the errors point at the source. Markdown parsers such as remark give every node a position with a line and a column. Report notes/caching.md:42:7: image has no alt text, and the author can fix the file without reading the compiler.

Expose the result as a virtual module#

addTemplate writes a generated module into .nuxt/, an alias gives it a stable import name, and addTypeTemplate declares its type. Pass { nuxt: true, nitro: true } to the type template so your server routes can import it too. Set write: true on the data template. An unwritten template lives only in Nuxt's virtual file system, which Vite reads and Nitro does not.

This minimal module has all of these parts, plus the hot reload from the next section:

modules/notes/index.ts
import { isAbsolute, join, relative } from 'node:path';
import { addTemplate, addTypeTemplate, defineNuxtModule, updateTemplates, useLogger } from '@nuxt/kit';
import { compileNotes, type Note } from './compile';

const TEMPLATE = 'notes/data.mjs';

export default defineNuxtModule({
    meta: { name: 'notes' },
    async setup(_options, nuxt) {
        const logger = useLogger('notes');
        const sourceDir = join(nuxt.options.rootDir, 'notes');
        const outDir = join(nuxt.options.rootDir, '.data', 'notes');
        let notes: Note[] = [];

        const template = addTemplate({
            filename: TEMPLATE,
            write: true,
            getContents: () => `export default ${JSON.stringify(notes)};\n`
        });
        nuxt.options.alias['#notes/data'] = template.dst;

        addTypeTemplate({
            filename: 'notes/data.d.ts',
            getContents: () => [
                'declare module \'#notes/data\' {',
                '    const notes: Array<{ slug: string; title: string; url: string }>;',
                '    export default notes;',
                '}',
                ''
            ].join('\n')
        }, { nuxt: true, nitro: true });

        /** Reads sourceDir, writes compiled files to outDir, throws on invalid input. */
        const build = async () => {
            notes = await compileNotes(sourceDir, outDir);
        };

        if (!nuxt.options._prepare) {
            if (nuxt.options.dev) {
                await build().catch(error => logger.error(error));
            } else {
                await build();
            }
        }

        // Content-addressed file names make a year-long cache safe.
        nuxt.options.nitro.publicAssets ||= [];
        nuxt.options.nitro.publicAssets.push({ dir: outDir, baseURL: '/_notes', maxAge: 60 * 60 * 24 * 365 });

        // Hot reload: one recompile at a time, then refresh the generated module.
        let reloadServer = async () => {};
        nuxt.hook('nitro:init', (nitro) => {
            reloadServer = () => nitro.hooks.callHook('rollup:reload');
        });

        let queue = Promise.resolve();
        const rebuild = () => {
            queue = queue.then(async () => {
                try {
                    await build();
                    await updateTemplates({ filter: t => t.filename === TEMPLATE });
                    await reloadServer();
                } catch (error) {
                    logger.error(error);
                }
            });
            return queue;
        };

        nuxt.options.watch.push(sourceDir);
        nuxt.hook('builder:watch', async (_event, path) => {
            const absolute = isAbsolute(path) ? path : join(nuxt.options.rootDir, path);
            const inside = relative(sourceDir, absolute);
            if (inside.startsWith('..') || isAbsolute(inside)) {
                return;
            }
            await rebuild();
        });
    }
});

App code and server routes then import #notes/data like any module, and never build a file URL from a slug by hand.

Hot reload that really fires#

Four separate mistakes can each make hot reload silently do nothing, and they hide each other.

Flowchart: a saved file reaches the Nuxt watcher only if its folder is registered in options.watch. The builder:watch hook receives an absolute path, which is checked against the source folder. Matching events trigger a recompile that runs one at a time, then updateTemplates, which makes the importers reload in the client and SSR graphs.Each step in the hot-reload path can fail without an error

First, Nuxt only watches the folders it knows about. A new top-level folder such as notes/ is outside every watched tree, so builder:watch never fires for it. I confirmed this by logging every event the hook received while I edited a file: there were none. Push the folder onto nuxt.options.watch.

Second, builder:watch passes the changed path as an absolute path. My first version compared it against a relative prefix such as notes/, which never matched. Resolve the path, then compare it with path.relative, which also copes with Windows separators.

Third, recompiles must not overlap. Switching git branches changes many files at once and fires one event per file. If each event starts its own compile, the runs race on the same output folder and the same in-memory data, and the run that finishes last wins even if it read older source. Chaining every run on one promise fixes that with three lines. Then updateTemplates regenerates the data module, which is what makes Vite invalidate its importers in both the client and the SSR module graphs.

Fourth, server routes do not get that update. Nitro's dev server bundles your server code separately, and it does not watch .nuxt/. A route that imports #notes/data keeps the data from startup, and its URLs point at files the recompile has already replaced. Capture the Nitro instance in the nitro:init hook and call its rollup:reload hook after updateTemplates. Nuxt uses the same hook for its own server rebuilds. Check it with a route that returns the data: edit a file, and the route's response must change too.

Start the dev server, edit a source file, and watch the page update with no restart. Then break the file on purpose: you should see your positioned error in the terminal while the server keeps running.

Content-addressed URLs for immutable caching#

Serving compiled files with a year-long immutable cache is safe only if the URL changes whenever the bytes change. The natural mistake is to hash the source files. I made it, and it broke in two ways. A compiler change, such as new syntax highlighting, produced different HTML from the same source, so browsers kept the old HTML for a year. A format change was worse: cached files no longer matched what the client code expected, so the page failed to load until the cache expired.

The rule is short: hash what you serve, not what you read.

modules/notes/emit.ts
import { createHash } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';

export async function emitJson(outDir: string, slug: string, data: unknown): Promise<string> {
    const json = JSON.stringify(data);
    const hash = createHash('sha256').update(json).digest('hex').slice(0, 16);
    const file = `${slug}.${hash}.json`;
    await writeFile(join(outDir, file), json);
    return `/_notes/${file}`;
}

Write the URL into your generated data module, so the client always asks for the current file. There is no version constant to remember to bump.

Images follow the same rule, with one extra step. Re-encode first, then hash the output, and record the real dimensions so the page reserves space before the image arrives:

modules/notes/images.ts
import { createHash } from 'node:crypto';
import sharp from 'sharp';

export async function encodeImage(input: Buffer) {
    const { data, info } = await sharp(input)
        .rotate()
        .resize({ width: 1536, height: 1536, fit: 'inside', withoutEnlargement: true })
        .webp({ quality: 80 })
        .toBuffer({ resolveWithObject: true });
    const name = `${createHash('sha256').update(data).digest('hex').slice(0, 16)}.webp`;
    return { name, data, width: info.width, height: info.height };
}

The top-level sharp import is fine here. This code runs at build time on your machine or CI, where fonts and native libraries are not a concern. Pass SVG through untouched and take its size from its viewBox. Reject an SVG that has none, because the browser cannot size it reliably.

Module traps worth knowing#

Nested files in a local module can fall outside type checking. Nuxt's generated tsconfig.node.json includes only one level of modules/. A type error in modules/notes/index.ts fails nuxi typecheck, while the same error in modules/notes/steps/parse.ts passes. Add the subfolders yourself, and prove it with a planted type error:

TypeScript
nuxt.hook('prepare:types', ({ nodeTsConfig }) => {
    nodeTsConfig.include ||= [];
    nodeTsConfig.include.push('../modules/notes/**/*.ts');
});

If your module changes another module's options, install yours first in nuxt.config.ts. Some modules, @nuxtjs/sitemap among them, copy their options during their own setup. A change you make after that copy has no effect, and nothing tells you.

If a dependency is imported only from lazily loaded routes, Vite's startup scan misses it. The first visit to that route makes Vite optimize the new dependency and reload the page. By hand that is a mild annoyance; in CI it makes end-to-end tests flaky. List those dependencies in vite.optimizeDeps.include. This affects only the dev server, not the production bundle.

Accessible reorder and matching controls#

Two of the quiz question types ask the learner to arrange things: put steps in order, or match each item on the left with one on the right. Both are usually built with drag-and-drop. Drag-and-drop alone fails keyboard users, screen reader users, voice control users, and anyone with a tremor. Both controls need a path that works without a pointer, and that path should be the primary one.

Reordering: buttons first, drag on top#

Give each row two native buttons, "Move up" and "Move down", and put the item's name in each button's accessible name. ARIA has no reorder pattern, so do not invent one. Plain buttons work with every assistive technology, and a voice control user can say "click Move Create the bucket up". After each move, announce the new position in a polite live region, for example "Create the bucket, position 2 of 5."

Two details make the buttons pleasant to use. Keep focus on the same item's button after the move, so a keyboard user can press it again to keep moving that item. At the ends of the list, use aria-disabled="true" with a guard in the handler instead of the disabled attribute. A button that becomes disabled while focused drops focus to the top of the page.

Drag then becomes an enhancement on top. I used SortableJS through VueUse's useSortable, which brings a problem of its own.

When two libraries animate the same move#

The list uses Vue's <TransitionGroup>, so a button move slides the rows into place. SortableJS also animates while you drag. On drop, Sortable has already moved the row in the DOM. Vue must own the DOM order, so the drop handler puts the row back where it was, then updates the model and lets Vue re-render.

That re-render is where it goes wrong. TransitionGroup records each row's position just before it patches the DOM, and at that moment the row is back at its old index. It then plays a FLIP animation from the old position to the new one. The user sees the row snap back and slide to the drop point a second time.

Sequence diagram: the user drags and drops, SortableJS animates the move, the drop handler restores the DOM, and the model changes. With the move class on, TransitionGroup plays a FLIP from the old positions and the row snaps back and slides. With the move class off for the drop, TransitionGroup skips the FLIP and the row stays put.Two libraries animating one drop

Let one library own each animation. TransitionGroup skips the FLIP when its move class has no transition. Switch the move class to one without a transition for the single re-render after a drop, then switch it back. The buttons keep their animation.

OrderList.vue
<template>
    <TransitionGroup
        ref="list"
        tag="ol"
        :move-class="moveClass">
        <li
            v-for="(item, i) in items"
            :key="item.id">
            <span>{{ item.label }}</span>
            <button
                v-for="step in steps"
                :key="step.name"
                :ref="el => setButton(item.id, step.name, el)"
                type="button"
                :aria-label="`Move ${item.label} ${step.name}`"
                :aria-disabled="atEnd(i, step.delta) || undefined"
                @click="move(item.id, step.name, step.delta)">
                {{ step.icon }}
            </button>
        </li>
    </TransitionGroup>
    <p
        class="sr-only"
        aria-live="polite">
        {{ status }}
    </p>
</template>

<script setup lang="ts">
    import { insertNodeAt, removeNode, useSortable } from '@vueuse/integrations/useSortable';
    import type { SortableEvent } from 'sortablejs';
    import type { ComponentPublicInstance } from 'vue';

    type Item = { id: string; label: string };

    const items = defineModel<Item[]>({ required: true });
    const status = ref('');

    const steps = [
        { name: 'up', delta: -1, icon: '↑' },
        { name: 'down', delta: 1, icon: '↓' }
    ] as const;

    const atEnd = (i: number, delta: number) => i + delta < 0 || i + delta >= items.value.length;

    function reorder(from: number, to: number) {
        const next = [...items.value];
        next.splice(to, 0, ...next.splice(from, 1));
        items.value = next;
        status.value = `${next[to]!.label}, position ${to + 1} of ${next.length}.`;
    }

    // Keyed by item and direction, so focus can follow the item that moved.
    const buttons = new Map<string, HTMLElement>();
    function setButton(id: string, name: string, el: Element | ComponentPublicInstance | null) {
        if (el instanceof HTMLElement) {
            buttons.set(`${id}:${name}`, el);
        }
    }

    async function move(id: string, name: string, delta: number) {
        const i = items.value.findIndex(item => item.id === id);
        if (atEnd(i, delta)) {
            return;
        }
        reorder(i, i + delta);
        await nextTick();
        buttons.get(`${id}:${name}`)?.focus();
    }

    // Sortable animates a drag itself, so TransitionGroup must not animate the drop again.
    const dropping = ref(false);
    const moveClass = computed(() => (dropping.value ? 'move-none' : 'move-slide'));

    const list = useTemplateRef('list');
    useSortable(list, items, {
        watchElement: true,
        animation: 150,
        async onUpdate({ item, from, oldIndex, newIndex }: SortableEvent) {
            removeNode(item);
            insertNodeAt(from, item, oldIndex!);
            dropping.value = true;
            reorder(oldIndex!, newIndex!);
            await nextTick();
            dropping.value = false;
        }
    });
</script>

<style>
    @media (prefers-reduced-motion: no-preference) {
        .move-slide {
            transition: transform 200ms ease;
        }
    }
</style>

move-none needs no CSS at all. Any class without a transition makes TransitionGroup skip the FLIP. For users who prefer reduced motion, also set Sortable's animation to 0. VueUse's usePreferredReducedMotion gives you that value reactively.

The drag handler calls the same reorder function as the buttons, so a drag announces the new position exactly like a button press. That leaves one code path to test.

Matching: a native select per row#

For matching, a native <select> per row beats any custom widget. Each row gets a <label> tied to its select, and the group of rows gets a name from the question text. Native selects already work with the keyboard, screen readers, voice control, and mobile pickers.

One rule needs care: each target can be used once. When the learner picks a target that another row already holds, move it, and tell them what happened. Clear the other row, mark it visibly, and announce the change:

TypeScript
const pairs = ref(new Map<string, string>()); // row id -> target id
const needsPair = ref(new Set<string>());

function pick(row: string, target: string) {
    const next = new Map(pairs.value);
    next.delete(row);
    needsPair.value.delete(row);
    if (target) {
        const owner = [...next].find(([, taken]) => taken === target)?.[0];
        if (owner) {
            next.delete(owner);
            needsPair.value.add(owner);
            status.value = `${labelOf(target)} moved. ${labelOf(owner)} needs a match again.`;
        }
        next.set(row, target);
    }
    pairs.value = next;
}

Point the row's select at a short "Needs a match again" note with aria-describedby, so a screen reader user who returns to that row hears why it is empty. Say the rule before the learner starts, too: "Each one is used exactly once."

If you want the select to match your design, style it with appearance: base-select where the browser supports it. This is progressive enhancement: older browsers keep the platform select, which is still fully accessible.

Testing all of this starts with putting the mouse away. Complete each question with the keyboard alone, then with a screen reader, then with voice control if you have it. Run axe on the page in both color modes. And drag one row with a real pointer, because the double animation does not appear in a keyboard test.

Two Nuxt traps that keep stale UI alive#

These two are small, but each one cost me an afternoon.

Keepalive that caches pages you did not ask for#

My home page has a "Load more" button, and I wanted the loaded posts to survive a round trip to an article. The quick fix was <NuxtPage keepalive /> in app.vue. It works, and it caches every page you visit.

That bit me later. A finished quiz result reappeared when the learner reopened the quiz, so they could not start fresh. A page's own definePageMeta({ keepalive: false }) cannot opt out, because the prop on <NuxtPage> wins.

The better pattern is opt-in. Leave <NuxtPage /> without the prop in app.vue. Then Nuxt keeps alive only the pages whose meta asks for it:

app/pages/index.vue
<script setup lang="ts">
    definePageMeta({ keepalive: true });
    defineOptions({ name: 'HomePage' });
</script>

The defineOptions line matters. Nuxt matches kept pages by component name, and a page's default name comes from its file name. Every index.vue in your app is named index: the home page, /quizzes/index.vue, /tools/index.vue, and so on. Opt one of them in, and you opt in all of them. Give the kept page a unique name.

Nitro auto-imports more than you think#

Nitro auto-imports everything under server/utils/, and that includes nested folders. If you organize a feature as server/utils/og/compose.ts, then helpers like escapeXml or geometry become global names across your whole server. Put feature code that should stay private in its own folder, such as server/og/, and import it explicitly. It is still type checked. It just stops leaking into every other file's scope.

Bonus: write a template once, render it twice#

The last recipe comes without a failure story: a small VueUse function I had never heard of saved me from writing a whole component.

The quiz shows a rail of question chips. On a tablet the chips sit inside a <details> disclosure, and on a desktop they sit in a sticky sidebar. The markup, the click handler and the state are the same in both places. The usual options are all poor. A copy drifts. A v-if on the screen width cannot work during server rendering, because the server does not know the viewport, so hydration renders the wrong layout first. A child component works, but every piece of state has to go down as a prop and every event has to come back up, for a component that is used twice in one file.

createReusableTemplate defines a piece of template inside a component and renders it as many times as you need. The template is a closure over the component, so it reads the script's state and calls its functions directly. The example below is a table of contents that shows in a disclosure on small screens and as a sidebar on wide ones:

TableOfContents.vue
<template>
    <DefineLinks v-slot="{ idPrefix }">
        <nav :aria-labelledby="`${idPrefix}-title`">
            <p :id="`${idPrefix}-title`">On this page</p>
            <ol>
                <li
                    v-for="heading in headings"
                    :key="heading.id">
                    <a
                        :href="`#${heading.id}`"
                        :aria-current="heading.id === active ? 'location' : undefined"
                        @click="select(heading.id)">
                        {{ heading.text }}
                    </a>
                </li>
            </ol>
        </nav>
    </DefineLinks>

    <details class="toc-compact">
        <summary>Contents</summary>
        <ReuseLinks id-prefix="toc-compact" />
    </details>
    <ReuseLinks
        class="toc-wide"
        id-prefix="toc-wide" />
</template>

<script setup lang="ts">
    import { createReusableTemplate } from '@vueuse/core';

    type Heading = { id: string; text: string };

    const { headings } = defineProps<{ headings: Heading[] }>();

    // Written once, rendered in the compact and the wide layout.
    const [DefineLinks, ReuseLinks] = createReusableTemplate<{ idPrefix: string }>({
        props: { idPrefix: { type: String, required: true } }
    });

    const active = ref<string>();
    function select(id: string) {
        active.value = id;
    }
</script>

<style>
    @media (width >= 64rem) {
        .toc-compact {
            display: none;
        }
    }

    @media (width < 64rem) {
        .toc-wide {
            display: none;
        }
    }
</style>

Hide the copies with CSS, not v-if#

Render every copy and let a media query hide the ones that do not fit. The server sends both, the client hydrates both, and nothing mismatches. display: none also removes the hidden copy from the tab order and from the accessibility tree, so a screen reader only ever finds the copy that is showing.

Pass per-copy values as declared props#

The generic parameter types the slot props on Define and the attributes on Reuse, so a misspelled prop fails type checking. Pass the props option as well. Without it, VueUse treats id-prefix as a plain attribute: the value still reaches the slot, but it also lands on the copy's root element as a stray id-prefix attribute. A declared prop stops that leak, and real attributes such as class still fall through to the root.

Make every id unique per copy#

Each Reuse is a full copy, so a literal id inside the template appears twice on the page. Anything that finds an element by id gets the first copy, even when that copy is hidden. I tested a <label for> in a reused template. In the visible copy, clicking the label focused nothing, and the input had no accessible name. Axe did not notice, because it skips hidden content. Derive every id from a prop, as idPrefix does above.

Know what the copies share#

The copies share the component's state. Click a link in one copy, and it becomes current in both. That is what you want here. If each copy needs its own state, such as its own open or closed flag, a child component is the right tool after all.

Two smaller rules. Define must render before any Reuse in the same template, or the render fails with "Failed to find the definition of reusable template", so put it first. And in Nuxt, the @vueuse/nuxt module auto-imports createReusableTemplate. Without that module, import it from @vueuse/core, as the snippet does.

The habit behind all four#

None of the code above took long to write. The time went into failures that looked like success: a 200, a valid PNG, a quiet watcher, a smooth animation on the keyboard path.

The same habit caught every one of them: before you trust a success signal, break the thing on purpose and see whether the signal notices. Delete the fonts and confirm that the route fails closed. Edit a file and confirm that the page changes without a restart. Drag with a real mouse. Validate the check before you believe its numbers.

If you want to see these pieces working together, take one of the quizzes and share your result. The image that unfurls was drawn on a Lambda that proved its fonts first.

Share:

Related Articles