How I display sheet music on my site

Written:

One feature I wanted on my site was the ability to show simple sheet music snippets.

Requirements:

  1. Must be exportable by musescore into a human editable format
  2. Must be viewable on site without javascript
  3. 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} />
Engraved by Verovio 5.7.0-7135340 Violin

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.

Engraved by Verovio 5.7.0-7135340 Viola arco Vla. 7 Vla. 14 mf V 1 3 III 1 Vla. 22 2 4 I 2 III 1 V 1 Vla. 29 4 III 3 2 I Resembool's Lullaby from Full Metal Alchemist: Brotherhood Akira Senju arr. Kennan Hunter

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.