How I display sheet music on my site
One feature I wanted on my site was the ability to show simple sheet music snippets.
Requirements:
- Must be exportable by musescore into a human editable format
- Must be viewable on site without javascript
- Bundle shouldn’t be excessively large
What format should we use?
SVGs scale cleanly and are the perfect solution for graphics like this. Musescore can export svgs directly, but I want the final code landing in my codebase to be human editable.
This leaves us with two working formats, MEI and MusicXML. Both are very similar, they’re XML based formats for describing music notation. Let’s compare them.
Here’s a simple A note in MusicXML:
<note>
<pitch>
<step>A</step>
<octave>5</octave>
</pitch>
<duration>1</duration>
<voice>1</voice>
<type>quarter</type>
<stem>down</stem>
</note>
Here’s the equivalent in MEI:
<note dur="4" pname="a" oct="5" />
Since MEI expresses the same musical idea with far less code, and the scores I’m working with are intentionally simple (they’re going on a blog, not in front of performing musicians) I chose MEI for its concision.
Compiling
Verovio is an engraving library, meaning it takes in our notation definitions then renders it into an svg. Although it’s written as a C++ library, they export wasm bindings and a javascript library that we can import and use.
In our server/verovio.ts file, we load in the verovio wasm bundle, cache it,
then wrap the main render function meiToSvg.
import createVerovioModule from "verovio/wasm";
import { VerovioToolkit } from "verovio/esm";
let toolkitPromise: Promise<VerovioToolkit> | null = null;
async function getToolkit(): Promise<VerovioToolkit> {
if (!toolkitPromise) {
toolkitPromise = createVerovioModule().then(
(module) => new VerovioToolkit(module),
);
}
return toolkitPromise;
}
export interface MeiToSvgOptions {
scale?: number;
pageHeight?: number;
pageWidth?: number;
svgViewBox?: boolean;
}
export async function meiToSvg(
mei: string,
options: MeiToSvgOptions = {},
): Promise<string> {
const toolkit = await getToolkit();
toolkit.setOptions({
scale: 40,
pageHeight: 2000,
pageWidth: 1200,
svgViewBox: true,
adjustPageHeight: true,
...options,
});
toolkit.loadData(mei);
return toolkit.renderToSVG(1);
}
Finally in our component MeiScore.astro we call the render function with our
mei in the server only frontmatter section. That svg string that’s rendered by
meiToSvg is passed into the component. Because of Astro’s magic, this compiled
svg is embedded straight into the HTML with no overhead.
---
import { meiToSvg, type MeiToSvgOptions } from "../server/verovio";
interface Props {
mei: string;
options?: MeiToSvgOptions;
class?: string;
}
const { mei, options = {}, class: className } = Astro.props;
const svg = await meiToSvg(mei, options);
---
<div class:list={["mei-score", "bg-white", className]} set:html={svg} />
Restrospective
When I started working on this feature, I was still thinking about the compilation lifecycle through a React/Vite lens. In similar situations with Vite, I’d had to write a Rollup plugin to handle the transformations, so that was the mental model I carried over.
Following that line of thought, I began exploring the idea of building a custom Astro renderer that would let us import MEI files directly as components. Thankfully, my friend Jack pointed out that I was making it more complicated than it needed to be.
If you had wanted to do this years ago, you probably would have to drop down to a shell script or Gulp (remember Gulp?) to handle rendering and asset inclusion. Astro makes a pipeline like this fit in two simple files. I genuinely think Astro is the good ending for the web dev story and I intend to use it wherever I can.
Demonstration
A Major
Read the MEI
Here’s the raw MEI xml file
<?xml version="1.0" encoding="UTF-8"?>
<?xml-model href="https://music-encoding.org/schema/5.1/mei-basic.rng" type="application/xml" schematypens="http://relaxng.org/ns/structure/1.0"?>
<?xml-model href="https://music-encoding.org/schema/5.1/mei-basic.rng" type="application/xml" schematypens="http://purl.oclc.org/dsdl/schematron"?>
<mei xmlns="http://www.music-encoding.org/ns/mei" meiversion="5.1+basic">
<meiHead>
<fileDesc>
<titleStmt>
<title type="main">A major scale</title>
<respStmt>
<persName role="composer">Composer / arranger</persName>
</respStmt>
</titleStmt>
<pubStmt>
<date isodate="2026-02-14T15:19:53" />
</pubStmt>
</fileDesc>
</meiHead>
<music>
<body>
<mdiv>
<score>
<scoreDef>
<pgHead>
<rend halign="left" valign="top">
<rend type="instrument_excerpt">Violin</rend>
</rend>
</pgHead>
<staffGrp>
<staffDef n="1" lines="5" keysig="3s" meter.count="4" meter.unit="4">
<label>Violin</label>
<labelAbbr>Vln.</labelAbbr>
<instrDef midi.instrnum="40" />
<clef shape="G" line="2" />
</staffDef>
</staffGrp>
</scoreDef>
<section xml:id="s1">
<measure xml:id="m89bk9x" n="1">
<staff xml:id="m1s1" n="1">
<layer xml:id="m1s1l1" n="1">
<note xml:id="nkjn5jf" dur="4" pname="a" oct="4" />
<note xml:id="n1a85uh5" dur="4" pname="b" oct="4" />
<note xml:id="nffma00" dur="4" pname="c" oct="5">
<accid accid.ges="s" />
</note>
<note xml:id="n11br9us" dur="4" pname="d" oct="5" />
</layer>
</staff>
</measure>
<measure xml:id="m763k9j" right="end" n="2">
<staff xml:id="m2s1" n="1">
<layer xml:id="m2s1l1" n="1">
<note xml:id="n1f5dxby" dur="4" pname="e" oct="5" />
<note xml:id="ntnyrmn" dur="4" pname="f" oct="5">
<accid accid.ges="s" />
</note>
<note xml:id="n1ckcjm2" dur="4" pname="g" oct="5">
<accid accid.ges="s" />
</note>
<note xml:id="n1eg9ge6" dur="4" pname="a" oct="5" />
</layer>
</staff>
</measure>
</section>
</score>
</mdiv>
</body>
</music>
</mei> Read the equivalent MusicXML
Here’s the equivalent MusicXML file. You can see it’s a lot longer.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<work>
<work-title>Untitled score</work-title>
</work>
<identification>
<creator type="composer">Composer / arranger</creator>
<encoding>
<software>MuseScore 4.5.2</software>
<encoding-date>2026-02-14</encoding-date>
<supports element="accidental" type="yes" />
<supports element="beam" type="yes" />
<supports element="print" attribute="new-page" type="no" />
<supports element="print" attribute="new-system" type="no" />
<supports element="stem" type="yes" />
</encoding>
</identification>
<part-list>
<score-part id="P1">
<part-name>Violin</part-name>
<part-abbreviation>Vln.</part-abbreviation>
<score-instrument id="P1-I1">
<instrument-name>Violin</instrument-name>
<instrument-sound>strings.violin</instrument-sound>
</score-instrument>
<midi-device id="P1-I1" port="1"></midi-device>
<midi-instrument id="P1-I1">
<midi-channel>1</midi-channel>
<midi-program>41</midi-program>
<volume>78.7402</volume>
<pan>0</pan>
</midi-instrument>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>1</divisions>
<key>
<fifths>3</fifths>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<note>
<pitch>
<step>A</step>
<octave>4</octave>
</pitch>
<duration>1</duration>
<voice>1</voice>
<type>quarter</type>
<stem>up</stem>
</note>
<note>
<pitch>
<step>B</step>
<octave>4</octave>
</pitch>
<duration>1</duration>
<voice>1</voice>
<type>quarter</type>
<stem>down</stem>
</note>
<note>
<pitch>
<step>C</step>
<alter>1</alter>
<octave>5</octave>
</pitch>
<duration>1</duration>
<voice>1</voice>
<type>quarter</type>
<stem>down</stem>
</note>
<note>
<pitch>
<step>D</step>
<octave>5</octave>
</pitch>
<duration>1</duration>
<voice>1</voice>
<type>quarter</type>
<stem>down</stem>
</note>
</measure>
<measure number="2">
<note>
<pitch>
<step>E</step>
<octave>5</octave>
</pitch>
<duration>1</duration>
<voice>1</voice>
<type>quarter</type>
<stem>down</stem>
</note>
<note>
<pitch>
<step>F</step>
<alter>1</alter>
<octave>5</octave>
</pitch>
<duration>1</duration>
<voice>1</voice>
<type>quarter</type>
<stem>down</stem>
</note>
<note>
<pitch>
<step>G</step>
<alter>1</alter>
<octave>5</octave>
</pitch>
<duration>1</duration>
<voice>1</voice>
<type>quarter</type>
<stem>down</stem>
</note>
<note>
<pitch>
<step>A</step>
<octave>5</octave>
</pitch>
<duration>1</duration>
<voice>1</voice>
<type>quarter</type>
<stem>down</stem>
</note>
<barline location="right">
<bar-style>light-heavy</bar-style>
</barline>
</measure>
</part>
</score-partwise> // the ?raw at the end of the path means to treat the file as a string
import a_major from "../../scores/sheet-music-demonstration-a-major-scale.mei?raw";
<MeiScore mei={a_major} />
A more complicated score
There’s definitely work to do. I want to look into how to change the font, some
of the spacings here don’t look great. There’s also weird artifacts caused by Musescore’s export,
like for example the missing pizz denotation from the beginning before the arco.
For reference here’s the pdf exported straight from musescore.
Future
I want to add some kind of interactivity. Maybe a play button that plays the notes as you click them, or a way to hover over a note and get info for people who don’t know how to read the clef or something.