For developers

Getting started

Publish a teaching page on GitHub Pages, load its libraries at fixed versions, use data that other websites allow you to read, and test it on a laptop and a phone before students do.

Every example on this site is a single HTML file that runs in the reader’s browser, with no server program behind it. This page covers what the examples have in common: where to publish them, how to load libraries so that they keep working, which data a page can read, how to test, and when a link serves students better than an embedded tool. Everything here was checked on 15 October 2026.

Publish a page with GitHub Pages

GitHub Pages turns the files in a GitHub repository into a website, free of charge. It serves files as they are and cannot run PHP, Ruby or Python on the server (GitHub Docs), so everything must run in the browser. That suits teaching pages, and this site is published the same way.

  1. Create a repository. On GitHub, choose New repository, give it a name and make it public. On GitHub’s free plan, Pages works only with public repositories.
  2. Add the first page. Put a file called index.html (or index.md) at the top of the repository, or in a folder called docs.
  3. Switch Pages on. In the repository, open Settings, then Pages. Under “Build and deployment”, set Source to Deploy from a branch, choose the branch (usually main) and the folder (/ (root) or /docs), and click Save (GitHub Docs).
  4. Wait, then visit. Changes can take up to 10 minutes to appear. Visit site, on the same settings page, opens the site. Its address has the form <user>.github.io/<repository>/.
  5. Add more files. Each file is published at the same path as in the repository, so examples/data/genes.bed appears at …/examples/data/genes.bed.

GitHub’s documentation lists the limits (GitHub Pages limits) and a warning:

  • A Pages site is public, even if the repository is private. Never put student data, or anything else you would not publish, in the repository.
  • A published site may be no larger than 1 GB, with a soft bandwidth limit of 100 GB a month. Leave whole genomes on the data servers, and keep small slices in your site.
  • Publishing from a branch runs Jekyll, which builds this site. Jekyll copies files without front matter unchanged. If you do not use Jekyll, add an empty file called .nojekyll at the top of the site.

How GitHub Pages serves files matters for genomics. When we checked with curl, it answered range requests with 206 Partial Content, so genome browsers can read indexed files from it; it sent Access-Control-Allow-Origin: *, so pages on other sites can read your files; and it sent Cache-Control: max-age=600, so browsers may show the old version of a page for up to 10 minutes after you change it. It does not let you add headers of your own, so features that need them, such as cross-origin isolation for interrupting R or Python code, need a workaround with a service worker.

Keep each example in one file

Each example on this site is one HTML file that works on its own. A reader can open it, download it and change it, and you can copy it into another site. The skeleton of such a file is short:

<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>HBB in a genome browser</title>
  <!-- One script per library, at an exact version, with its integrity hash -->
  <script src="https://cdn.jsdelivr.net/npm/igv@3.8.9/dist/igv.min.js"
          integrity="sha384-J2SJJANecDBpqn++zIO/AJY/Fpk64lYDp43ibq7bmQONxd9b80ZpvLKBrKdCtYIu"
          crossorigin="anonymous"></script>
  <style>
    body { font: 15px/1.5 system-ui, sans-serif; margin: 0; padding: 12px 16px; background: #fff; }
  </style>
</head>
<body>
  <h1>The human β-globin gene, HBB</h1>
  <p id="feedback" role="status" aria-live="polite"></p>
  <div id="igv"></div>
  <script>
    // Comments say why, for the next person who changes this file
    igv.createBrowser(document.getElementById("igv"), { genome: "hg38", locus: "HBB" });
  </script>
</body>
</html>

What each part does:

  • lang and the viewport line. Screen readers use the language, and phones need the viewport line to lay the page out at their own width.
  • Exact versions and hashes, explained in the next section.
  • Labelled controls and a live message. Every input has a label, every button is <button type="button">, and feedback goes into an element with aria-live="polite". The accessibility page explains why.
  • Data next to the file. Small data files go in a data folder beside the example, with a README.txt that says where each came from.

Opened straight from the disk, with a file:// address, a page can still load its libraries and read data from servers that allow other websites. It cannot read the files beside it: Chromium refuses with “URL scheme ‘file’ is not supported”. Serve the folder with a local server instead, as described below.

Fix the version and check the hash

Libraries keep changing, so an address that does not name a version can give your page different code from one day to the next. An address such as igv@latest, or a version range such as igv@3, gets updates automatically, and jsDelivr warns that the code behind such an address “can change any time” (jsDelivr). Name the exact version instead, as in igv@3.8.9. Libraries for teaching move quickly: Mol* went from version 5.10 to 5.13 in about four months, and react-msaview released three major versions, 7.0, 8.0 and 9.0, between 14 September and 12 October 2026.

Subresource integrity (SRI) adds a second check. The integrity attribute holds a hash of the exact file, and the browser refuses to run a download whose hash differs (MDN). A file from another site also needs crossorigin="anonymous", because the check works only when that site allows other websites to read the file. To make the hash:

curl -s https://cdn.jsdelivr.net/npm/igv@3.8.9/dist/igv.min.js | openssl dgst -sha384 -binary | openssl base64 -A

A few cautions:

  • Hash only files that never change. jsDelivr advises SRI only for full links to single files at fixed versions, because the files it minifies or combines on request can change (jsDelivr).
  • ES modules can carry hashes in the integrity section of an import map. Chromium enforced them in our tests: a wrong hash blocked the module.
  • Some libraries load more files themselves. webR and Pyodide download many further files after the first, and a hash covers only the file named in the tag. For these, the exact version in the address is the protection.
  • Update on purpose. Read the release notes, change the version and the hash together, and test the page again.

Plan for networks that block a CDN

A content delivery network (CDN) such as jsDelivr serves libraries from servers close to the reader. Most examples on this site use jsDelivr. Many files are also on unpkg or cdnjs, byte for byte the same: igv.js 3.8.9 has the same hash on unpkg as on jsDelivr. But a school, hospital or company network may block a CDN, or a particular site such as the one webR loads R from, so test from the room you will teach in. Some libraries fetch from more than one host: when hgdownload.soe.ucsc.edu does not answer, igv.js 3.8.9 quietly fetches the same files from UCSC’s mirror at genome-browser.s3.us-east-1.amazonaws.com, so ask for both to be allowed.

The reliable answer is a copy of the library next to your page:

mkdir -p vendor
curl -sL -o vendor/igv.min.js https://cdn.jsdelivr.net/npm/igv@3.8.9/dist/igv.min.js
openssl dgst -sha384 -binary vendor/igv.min.js | openssl base64 -A    # must match the hash in your page

Then point the script tag at vendor/igv.min.js, keep the integrity attribute, and keep the library’s licence file beside it. In our tests, an igv.js page and a JBrowse 2 page that loaded their library and data from the same site kept working with every outside request blocked. webR and Pyodide are harder, because they load many megabytes of further files. Pyodide worked offline on a second visit while the page itself was still in the browser’s cache. webR did not start offline even after a visit, because at start-up it asks its server for a file in a way that Chromium did not answer from its cache in our tests.

Data need as much care as libraries, because public servers are sometimes slow or down: during our tests, Ensembl’s REST API failed 32 of 45 requests over about 40 minutes, and Zenodo failed now and then. Where a dataset’s licence allows it, host a small copy next to the page.

Use data that other websites let you read

A page can show an image from any site, but its code can read a file from another site only if that site’s answer includes the header Access-Control-Allow-Origin. The rule is called cross-origin resource sharing, or CORS (MDN). Genome browsers, structure viewers and chart libraries all read their data with code, so the rule applies to every data file a teaching page uses.

Three details often catch people out:

  • Errors look like refusals. Many servers leave the header off their error messages, so a busy server looks the same to a page as one that never allows other websites: the browser reports only that the request failed. Ensembl’s REST API does this (genome browsers page), and so did Zenodo during our tests.
  • Most headers stay hidden. A page can read only a few headers of another site’s answer, such as Content-Type and Content-Length, unless the server lists more in Access-Control-Expose-Headers (MDN). NCBI lists Content-Range, which gives a file’s full size, but UCSC and GitHub Pages do not.
  • A page served over https cannot read http addresses. Browsers block the request as mixed content (MDN), so data addresses must start with https://.

Range requests matter for large indexed files. Formats such as BAM, CRAM, VCF compressed with bgzip, bigWig, bigBed and 2bit are read in pieces: the browser asks for a range of bytes, and a server that supports this answers 206 Partial Content with just that piece. A server that does not answers 200 and sends the whole file (MDN). A request for a single range of bytes counts as a simple request under CORS, so the browser sends it without asking the server’s permission first (MDN).

These sources behaved as follows when we checked them:

Source Other websites may read it Answers range requests
UCSC downloads (hgdownload.soe.ucsc.edu) and the UCSC API Yes Yes (downloads)
1000 Genomes at EBI (ftp.1000genomes.ebi.ac.uk) Yes Yes
NCBI, including ClinVar (ftp.ncbi.nlm.nih.gov) Yes Yes
UniProt REST API Yes FASTA yes; JSON only sometimes
RCSB PDB files (files.rcsb.org) Yes No: it sends the whole file
PDBe, the AlphaFold DB API and the STRING API Yes Not checked
Zenodo, API address (zenodo.org/api/records/…/content) Yes Yes
Zenodo, download address (…?download=1) No Yes
GitHub Pages Yes Yes
Galaxy servers’ API No, except the new “landing” endpoints Not checked
ENCODE portal download links Refused in our tests: use the file’s public Amazon S3 address Yes, from S3
TreeBASE downloads and the Microreact API No Not checked

Check a data address before class

This checker asks a server for the first 100 bytes of a file, as a genome browser does, and reports whether other websites may read it, whether it answers range requests, how big the file is and what kind of file it appears to be.

The file next to the page is the first exon of HBB in FASTA format; its README says where it came from. Zenodo was slow and sometimes failed during our tests, and a failure looks the same as a refusal, so try the Zenodo examples twice.

Code check-data-url.html
<!doctype html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Check whether a teaching page can read a data file</title>
  <style>
    body { font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 12px 16px 16px; color: #172033; background: #fff; }
    h1 { font-size: 1.05rem; margin: 0 0 6px; }
    p { margin: 0 0 8px; }
    label { display: block; font-weight: 600; font-size: 14px; margin: 8px 0 3px; }
    select, input, button { font: inherit; font-size: 14px; }
    select, input { width: 100%; box-sizing: border-box; padding: 5px 8px; border: 1px solid #8a94a6; border-radius: 6px; background: #fff; color: #172033; }
    button { margin-top: 10px; min-height: 32px; padding: 5px 16px; border: 1px solid #172033; border-radius: 6px; background: #172033; color: #fff; cursor: pointer; }
    button:disabled { opacity: 0.6; cursor: progress; }
    :focus-visible { outline: 3px solid #2b56c9; outline-offset: 2px; }
    #summary { margin: 12px 0 6px; font-weight: 700; min-height: 1.5em; }
    dl { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 14px; font-size: 14px; }
    dt { font-weight: 600; }
    dd { margin: 0; }
    pre { font: 13px/1.45 ui-monospace, Menlo, Consolas, monospace; background: #f4f6fa; border: 1px solid #dde2ea; border-radius: 6px; padding: 6px 8px; margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
    @media (max-width: 480px) { dl { grid-template-columns: minmax(0, 1fr); gap: 2px; } dd { margin-bottom: 8px; } }
  </style>
</head>
<body>
  <h1>Check a data address before class</h1>
  <p>The check asks for the first 100 bytes of a file, as igv.js and JBrowse do when they read an indexed file.</p>

  <form id="form">
    <label for="preset">Choose an example</label>
    <select id="preset">
      <option value="data/hbb-exon1.fa">A file next to this page (HBB exon 1, FASTA)</option>
      <option value="https://hgdownload.soe.ucsc.edu/goldenPath/hg38/bigZips/hg38.chrom.sizes">UCSC: GRCh38 chromosome sizes</option>
      <option value="https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/archive_2.0/2026/clinvar_20261004.vcf.gz">NCBI: the ClinVar VCF of 4 October 2026 (200 MB)</option>
      <option value="https://files.rcsb.org/download/4HHB.cif">RCSB PDB: haemoglobin, entry 4HHB</option>
      <option value="https://zenodo.org/records/2596382/files/limma-voom_luminalpregnant-luminallactate?download=1">Zenodo: a results table, by its download link</option>
      <option value="https://zenodo.org/api/records/2596382/files/limma-voom_luminalpregnant-luminallactate/content">Zenodo: the same table, by its API link</option>
    </select>
    <label for="url">Address to check (or paste your own)</label>
    <input id="url" type="text" inputmode="url" spellcheck="false" autocomplete="off">
    <button type="submit" id="go">Check</button>
  </form>

  <p id="summary" role="status" aria-live="polite"></p>
  <dl id="details"></dl>

  <script>
    const form = document.getElementById("form");
    const preset = document.getElementById("preset");
    const input = document.getElementById("url");
    const go = document.getElementById("go");
    const summary = document.getElementById("summary");
    const details = document.getElementById("details");

    input.value = preset.value;
    preset.addEventListener("change", () => { input.value = preset.value; });
    form.addEventListener("submit", e => { e.preventDefault(); check(input.value.trim()); });

    // One finding: a term and what it means. Code-like values go in a <pre>.
    function add(term, text, asCode) {
      const dt = document.createElement("dt");
      dt.textContent = term;
      const dd = document.createElement("dd");
      if (asCode) {
        const pre = document.createElement("pre");
        pre.textContent = text;
        dd.append(pre);
      } else {
        dd.textContent = text;
      }
      details.append(dt, dd);
    }

    const STATUS = { 401: "login needed", 403: "forbidden", 404: "not found", 410: "gone", 416: "range not available",
                     429: "too many requests", 500: "server error", 502: "bad gateway", 503: "service unavailable" };

    const size = n => n.toLocaleString("en-GB") + " bytes" +
      (n >= 1e6 ? " (" + (n / 1e6).toFixed(n >= 1e8 ? 0 : 1) + " MB)" : n >= 1e3 ? " (" + (n / 1e3).toFixed(1) + " kB)" : "");

    // Recognise common genomics formats from their first bytes
    function describe(bytes) {
      const hex = Array.from(bytes.slice(0, 4), x => x.toString(16).padStart(2, "0")).join(" ");
      if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
        // bgzip output (BGZF) is gzip with an extra field named "BC" at bytes 12 and 13.
        // An index (.tbi, .csi, .bai) can point into a BGZF file, but not into ordinary gzip.
        const bgzf = (bytes[3] & 4) && bytes[12] === 0x42 && bytes[13] === 0x43;
        return bgzf ? "compressed with bgzip (BGZF), so an index can point into it"
                    : "compressed with ordinary gzip, so an index cannot point into it: recompress it with bgzip";
      }
      if (hex === "43 27 41 1a") return "a UCSC 2bit sequence file";
      if (hex === "26 fc 8f 88") return "a bigWig file";
      if (hex === "eb f2 89 87") return "a bigBed file";
      const text = new TextDecoder().decode(bytes);
      // Binary data decodes with control characters or replacement characters (U+FFFD) in it.
      // The last character may be a letter cut in half at byte 100, so it is ignored.
      if (/[\u0000-\u0008\u000e-\u001f]/.test(text) || text.slice(0, -1).includes("�")) return "binary data";
      if (text.startsWith(">")) return "text, in FASTA format";
      if (text.startsWith("##fileformat=VCF")) return "text, in VCF format";
      if (text.startsWith("data_")) return "text, in mmCIF format";
      return "text";
    }

    // Text is shown as it is; anything else as hexadecimal byte values
    function firstBytes(bytes) {
      const start = bytes.subarray(0, 100);
      if (/^text/.test(describe(bytes))) return new TextDecoder().decode(start);
      return Array.from(start.subarray(0, 32), x => x.toString(16).padStart(2, "0")).join(" ") + (start.length > 32 ? " …" : "");
    }

    async function check(address) {
      details.replaceChildren();
      let url;
      try { url = new URL(address, location.href); }
      catch (err) { summary.textContent = "That is not a web address."; return; }

      if (location.protocol === "file:" && url.protocol === "file:") {
        summary.textContent = "This page is open from your disk, so the browser will not let it read files.";
        add("What to do", "Serve the folder with a local web server, such as tools/serve.py from this site's repository, or check an https:// address.");
        return;
      }
      if (url.protocol !== "https:" && url.protocol !== "http:") {
        summary.textContent = "Only http:// and https:// addresses can be checked.";
        return;
      }
      if (location.protocol === "https:" && url.protocol === "http:") {
        summary.textContent = "A page served over https:// cannot read files from plain http:// addresses.";
        add("Why", "Browsers block it as mixed content. Use the file's https:// address, if it has one.");
        return;
      }

      go.disabled = true;
      summary.textContent = "Checking…";
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), 20000);   // a busy server can take a long time: give up after 20 s
      const started = performance.now();
      try {
        let response;
        try {
          // A single byte range is a "CORS-safelisted" header, so the browser sends the request at once,
          // as it does for a genome browser reading an index, without asking the server's permission first.
          response = await fetch(url, { headers: { Range: "bytes=0-99" }, cache: "no-store", signal: ctrl.signal });
        } catch (err) {
          if (err.name === "AbortError") {
            summary.textContent = "No answer within 20 seconds.";
            add("What to do", "Try again later. A server this slow will be slow for a class too.");
            return;
          }
          // The browser does not tell the page why a request failed. Ask again in "no-cors" mode: that answer
          // cannot be read either, but if it arrives, the server is reachable and refused to let this page read it.
          const probe = new AbortController();
          const probeTimer = setTimeout(() => probe.abort(), 10000);
          const reachable = await fetch(url, { method: "HEAD", mode: "no-cors", cache: "no-store", signal: probe.signal })
            .then(() => true, () => false);
          clearTimeout(probeTimer);
          if (reachable) {
            summary.textContent = "The server answered, but this page was not allowed to read the answer.";
            add("Other websites", "Not allowed: the answer had no Access-Control-Allow-Origin header. Busy servers also leave it off their error messages, so try again before you decide.");
            add("What to do", "Host a copy next to your page, if the licence allows it, or look for another address for the same file. The two Zenodo examples are one file at two addresses.");
          } else {
            summary.textContent = "The browser could not reach the server.";
            add("What to do", "Check the address and your connection. A network filter may also block the site.");
          }
          return;
        }

        // Read the first bytes. A 206 answer holds only the bytes asked for. From any other answer,
        // read one piece and stop, so that a large file is not downloaded in full.
        let bytes = new Uint8Array(0);
        if (response.status === 206) {
          bytes = new Uint8Array(await response.arrayBuffer());
        } else if (response.body) {
          const reader = response.body.getReader();
          const piece = await reader.read();
          if (piece.value) bytes = piece.value;
          if (!piece.done) reader.cancel();
        }
        const ms = Math.round(performance.now() - started);
        const sameSite = url.origin === location.origin;
        const status = response.status;

        if (!response.ok) {
          summary.textContent = "The server answered with an error: HTTP " + status + (STATUS[status] ? " (" + STATUS[status] + ")." : ".");
          add("What to do", status === 404 ? "Check the address: there is no file there." : "Check the address, or try again later.");
          return;
        }

        const ranges = status === 206;
        if (sameSite) {
          summary.textContent = ranges ? "This page can read the file, and the server answers range requests."
                                       : "This page can read the file, but the server sends it whole.";
          add("Other websites", "This file is on the same site as the page, so the browser reads it without asking. For pages on other sites, the server must send Access-Control-Allow-Origin. GitHub Pages and tools/serve.py both do.");
        } else {
          summary.textContent = ranges ? "Other websites can read this file, and the server answers range requests."
                                       : "Other websites can read this file, but the server sends it whole.";
          add("Other websites", "Allowed: the server sent Access-Control-Allow-Origin, so the browser let this page read the answer.");
        }
        add("Range requests", ranges
          ? "Answered: the server sent only the 100 bytes asked for (HTTP 206). igv.js and JBrowse can read indexed files from here."
          : "Not answered: the server sent the whole file (HTTP " + status + "), and this page stopped after the first piece. A page can still read a small file whole, but a genome browser cannot read an indexed file from here.");

        // Content-Range ("bytes 0-99/200145964") gives the full size, but a page can read it only if the server
        // lists it in Access-Control-Expose-Headers. Content-Length is always readable.
        const range = response.headers.get("Content-Range");
        const total = range && /\/(\d+)$/.exec(range);
        const length = response.headers.get("Content-Length");
        add("File size", total ? size(Number(total[1]))
          : ranges ? "Not shown: the server does not let pages read its Content-Range header. UCSC's servers are the same, and genome browsers read their files without it."
          : length ? size(Number(length))
          : "Not shown: the server did not say.");
        const type = response.headers.get("Content-Type");
        if (type) add("Content type", type);
        add("The file is", describe(bytes));
        add("Start of the file", firstBytes(bytes), true);
        add("Time taken", ms.toLocaleString("en-GB") + " ms");
      } catch (err) {
        summary.textContent = err.name === "AbortError" ? "The answer stopped arriving within 20 seconds." : "The check failed: " + err.message;
      } finally {
        clearTimeout(timer);
        go.disabled = false;
      }
    }

    check(input.value);   // start with the file next to this page
  </script>
</body>
</html>

How it works:

  • The request is fetch(url, { headers: { Range: "bytes=0-99" } }). A single range of bytes needs no permission check, as above.
  • Reading the answer. A 206 answer holds only the bytes asked for. From a 200 answer the page reads one piece of the download and cancels the rest, so a large file is not fetched in full.
  • Telling a refusal from an unreachable server. When fetch fails, the browser does not tell the page why. The checker then sends a HEAD request in no-cors mode. That answer cannot be read either, but if it arrives, the server is reachable and did not let the page read the file.
  • Recognising formats. Files compressed with bgzip begin like gzip files but carry an extra field named BC, and only these can be read through an index such as a .tbi. The checker also recognises 2bit, bigWig and bigBed files from their first four bytes.
  • Files on the same site are always readable by the page. To check whether other sites can read your own files, paste their address into the checker on this page.

Test on your own computer

For this site’s repository, or any Jekyll site:

bundle exec jekyll build --baseurl ""                                   # build the site into _site
python3 tools/serve.py 8800 _site                                       # serve it at http://127.0.0.1:8800/
python3 tools/check.py                                                  # template braces, exact versions, broken links
python3 tools/test_examples.py http://127.0.0.1:8800 getting-started    # run the examples in Chromium

tools/serve.py answers range requests and sends Access-Control-Allow-Origin: *, as GitHub Pages does. It also exposes Content-Range, which GitHub Pages does not, and tells the browser not to cache anything. Python’s built-in server (python3 -m http.server) does not answer range requests, so indexed files fail with it. tools/test_examples.py opens each example in headless Chromium, reports console errors, failed requests and HTTP errors, and saves a screenshot in tools/screenshots/; it needs Python Playwright.

While you work, keep the browser’s developer tools open on the Console and Network tabs, where refused requests, missing files and blocked mixed content show up. We test in Chromium only, so try important pages in Firefox and Safari as well, and on the tablets your students use.

Check the page on a phone

Many students will open your teaching pages on a phone, so include the viewport line, avoid fixed widths, and put wide tables in a box that scrolls sideways. In a CSS grid, use minmax(0, 1fr) rather than 1fr for columns that hold wide content: in our tests, a plain 1fr column made a network page wider than a 412-pixel phone.

This is what we found at phone width:

Tool On a phone
igv.js 3.8.9 Its toolbar needs about 745 pixels, or about 473 with the optional buttons hidden, so a 390-pixel phone zooms the whole page out. There is no pinch to zoom.
JBrowse 2 linear genome view 4.3.0 Fits a 390-pixel screen
Mol*, PDBe Mol*, NGL and 3Dmol.js Worked at 412 pixels wide; the Mol* pages needed no sideways scrolling
Nightingale and react-msaview Fit a 390-pixel screen, but the residues become very small
Cytoscape.js Fits once grid columns use minmax(0, 1fr)
JupyterLab Crowded; the simpler Notebook interface works better
webR and Pyodide code boxes Built to fit a phone

Check with the device emulation in your browser’s developer tools, then on a real phone, on the network students will use.

Some services refuse to appear inside another site’s page, and some are better used whole. This is what we found when we tested them:

Service In a frame on your page Instead
UCSC Genome Browser Its check for automated visitors runs inside the frame Link in a new tab, show a picture from hgRenderTracks, or use the UCSC API for data
Ensembl Refused (X-Frame-Options: SAMEORIGIN) Link in a new tab; use the REST API for data
Galaxy Refused by usegalaxy.org and .org.au, except published pages opened with embed=true; usegalaxy.fr refused those too on 16 October 2026 Link to a tool, a workflow or a published history
STRING The website sits behind a check for automated visitors Draw the network yourself from the STRING API
FinnGen Refused (X-Frame-Options: SAMEORIGIN) Link out; its API answers other websites
JBrowse Web Allowed Host your own copy with analytics switched off, because the copy at jbrowse.org contacts Google Analytics
Nextstrain and Microreact Allowed Embed a dated snapshot, and give a direct link too, because their accessibility is out of your hands
JupyterLite Allowed Files a student saves inside the frame are kept apart from the same site opened directly

Link out when a service refuses frames, when students must log in, when the whole tool is the point of the lesson, or when an embedded view would be too small on a phone. The topic pages give working links for genome browsers and the other tools.

Know the licences of libraries and data

The libraries used on this site all have open-source licences that allow their use in teaching pages. So do the smaller libraries that some of them need, such as underscore for phylotree.js and the layout extensions for Cytoscape.js, which are under MIT. When you save a copy of a library, keep its licence notice with it.

Library Version we tested Licence
igv.js 3.8.9 MIT
JBrowse 2 4.3.0 Apache 2.0; the linear genome view package itself is labelled MIT
Nightingale 5.11.0 MIT
react-msaview 8.4.1 MIT
SeqViz 3.10.25 MIT
Mol* 5.12.0 MIT
PDBe Mol* 3.12.0 Apache 2.0
NGL 2.5.0 MIT
3Dmol.js 2.5.5 BSD 3-clause
Cytoscape.js 3.34.3 MIT
phylotree.js 2.6.0 MIT
Phylocanvas.gl 1.64.1 MIT
d3 7.9.0 ISC
Vega, Vega-Lite and vega-embed 6.4.0, 6.4.3 and 7.3.0 BSD 3-clause
Plotly.js 4.1.1 MIT
Observable Plot 0.6.17 ISC
webR 0.6.0 MIT; the R binaries it loads are GPL
Pyodide 314.0.7 Mozilla Public License 2.0
JupyterLite 0.8.5 BSD 3-clause

Data come with terms of use as well. Protein Data Bank files are in the public domain (CC0). AlphaFold DB models and STRING networks are under CC BY 4.0, so credit the source. Data from UCSC, ClinVar, ENCODE and the 1000 Genomes Project are free to use under each provider’s terms: cite them, and check the terms before you copy files into your site. FinnGen asks users to acknowledge and cite it, and we found no licence that allows redistribution, so link to its data rather than copying them. TreeBASE files state no licence. For human data, also read genomic data and ethics.

This site’s own text is under CC BY 4.0 and its code, including every example, under the MIT licence (about this site).