How to Set Up Zotero, Better BibTeX, and Neovim for Academic Citations

From manual footnotes to automated @citekey insertion with zotcite

tutorial · 30% AI

Post 2026-A-0047

Prerequisites

Introduction

Citation management is one of the most tedious parts of long-form essay writing. The workflow of copy-pasting URLs, formatting notes by hand, and renumbering footnotes when you insert a reference in the middle of a draft is fragile and slow. This tutorial walks through a better approach: Zotero as your reference library, Better BibTeX to auto-generate citekeys and bibliography files, and the zotcite Neovim plugin to insert [^1] references directly from your editor. The result is a file you can run through pandoc with --citeproc to produce formatted footnotes and bibliography automatically.

By the end of this tutorial, you will have:

  1. Zotero installed and populated with your references
  2. Better BibTeX exporting a .bib file that updates automatically
  3. zotcite installed in Neovim with working keybindings
  4. The render pipeline in render-post.py extended to resolve citekeys
  5. A script to import existing manual [^N] footnotes from an essay into Zotero
  6. A script to replace [^N] markers with [^2] patterns

Step 1: Install and Configure Zotero

Install Zotero 7 from the official website or via Homebrew:

brew install --cask zotero

Launch Zotero. It opens with an empty “My Library” – this is your reference database. Each entry you add becomes a row in a SQLite database located at ~/Zotero/zotero.sqlite.

To add references, you have three options:

For bulk import of existing footnotes, skip ahead to Step 5, which provides a script.

Verification

Open Zotero and confirm you see items in your library. Close Zotero so the database is not locked, then run:

sqlite3 ~/Zotero/zotero.sqlite "SELECT COUNT(*) FROM items;"

You should see a number greater than zero.

Step 2: Install Better BibTeX

Better BibTeX is a Zotero plugin that generates clean, human-readable citekeys and auto-exports your library as a .bib (BibTeX) file.

  1. In Zotero, go to Tools -> Add-ons
  2. Click the gear icon -> Install Add-on From File
  3. Alternatively, download the latest release from https://retorque.re/zotero-better-bibtex/ and drag the .xpi file into Zotero’s Add-ons window
  4. Restart Zotero

Configure Auto-Export

  1. In Zotero, select your library
  2. File -> Export Library
  3. Format: Better BibTeX
  4. Check Keep updated (this is critical – it enables automatic re-export whenever your library changes)
  5. Save to ~/Zotero/bibliography.bib

You can verify the export path in Zotero’s preferences under Better BibTeX -> Automatic export – you should see an entry for the file you just created.

Verification

Check that your .bib file exists and contains entries:

head -5 ~/Zotero/bibliography.bib
grep '^@' ~/Zotero/bibliography.bib | wc -l

You should see entries starting with [^3]{, [^4]{, [^5]{, etc., using citekeys like [^6]. The @ prefix in the first line of each entry is the citekey you will use in your documents.

Step 3: Install and Configure zotcite in Neovim

zotcite is a Lua plugin for Neovim that reads your Zotero database (or .bib file) and lets you search and insert citations from within your editor.

Add the following to your Neovim plugin configuration. If you use lazy.nvim, add this to your plugin spec list (usually in ~/.config/nvim/init.lua within the require("lazy").setup({...}) block):

  {
    "jalvesaq/zotcite",
    ft = { "markdown", "pandoc" },
    opts = {
      key_type = "better-bibtex",
      zotero_sqlite_path = vim.fn.expand("$HOME") .. "/Zotero/zotero.sqlite",
    },
    init = function()
      -- ftplugin handles keymaps automatically
    end,
  },

Reload lazy.nvim by restarting Neovim, or run:

nvim --headless "+Lazy sync" +qa

Understanding the Keybindings

zotcite registers the following keymaps in .md and .pandoc files:

Key Mode Action
<C-X><C-B> Insert Search Zotero and insert a [^7]
<leader>zi Normal Show citation info under cursor
<leader>zo Normal Open the Zotero attachment under cursor
<leader>zv Normal View document

Verification

Open a markdown file in Neovim, switch to insert mode, and press <C-X><C-B>. A completion menu should appear showing your Zotero items. Type a few letters to narrow the search. Use <C-N> and <C-P> to navigate, and <C-Y> to select and insert a [^8].

Step 4: Extend the Render Pipeline

Once your markdown contains [^9] references, you need a renderer that resolves them into formatted footnotes. The following Python function reads your .bib file, scans the markdown for [^10] patterns, replaces each with a [^N] marker, and appends [^N]: footnote entries to the end of the body.

import re
from pathlib import Path

BIB_FILE = Path.home() / 'Zotero' / 'bibliography.bib'

def resolve_citations(body_md: str) -> str:
    if not BIB_FILE.exists():
        return body_md

    bib_text = BIB_FILE.read_text(encoding='utf-8')
    entries = {}

    # Parse each [^11]{citekey, ...}
    for m in re.finditer(r'@\w+\s*\{\s*([^,]+)\s*,', bib_text):
        citekey = m.group(1).strip()
        start = m.end()
        depth = 1
        pos = start
        while pos < len(bib_text) and depth > 0:
            if bib_text[pos] == '{': depth += 1
            elif bib_text[pos] == '}': depth -= 1
            pos += 1
        entry_body = bib_text[start:pos - 1] if depth == 0 else bib_text[start:]

        fields = {}
        for line in entry_body.split('\n'):
            fm = re.match(r'^\s*(\w[\w-]*)\s*=\s*\{', line)
            if fm:
                fname = fm.group(1)
                fstart = fm.end()
                fdepth = 1
                fpos = fstart
                while fpos < len(line) and fdepth > 0:
                    if line[fpos] == '{': fdepth += 1
                    elif line[fpos] == '}': fdepth -= 1
                    fpos += 1
                fval = line[fstart:fpos - 1].strip() if fdepth == 0 else line[fstart:].strip()
                if fval:
                    fields[fname.lower()] = fval

        author = fields.get('author', '').replace('{', '').replace('}', '')
        title = fields.get('title', '').replace('{', '').replace('}', '')
        year = fields.get('year', '')
        journal = (fields.get('journal', '') or fields.get('booktitle', '') or '').replace('{', '').replace('}', '')
        url = fields.get('url', '')

        parts = []
        if author: parts.append(author)
        if title: parts.append(f'"{title}"')
        if journal: parts.append(f'*{journal}*')
        if year: parts.append(f'({year})')
        ref = ', '.join(parts)
        if url: ref += f', {url}'
        entries[citekey] = ref

    if not entries:
        return body_md

    collected = []
    counter = [0]

    def _replace(m):
        counter[0] += 1
        n = counter[0]
        inner = m.group(1) or m.group(2) or ''
        keys = re.findall(r'@(\S+)', inner)
        if not keys:
            keys = [inner.strip()]
        refs = []
        sep = "; "
        for k in keys:
            k = k.rstrip(',;. \t')
            refs.append(entries.get(k, f'{k} (not found)'))
        collected.append(f'[^{n}]: {sep.join(refs)}')
        return f'[^{n}]'

    body_md = re.sub(r'\[^12]]+)\]|(?<!\w)@(\w[\w-]*)(?!\w)', _replace, body_md)

    if collected:
        body_md += '\n\n---\n\n## Notes\n\n'
        body_md += '\n\n'.join(collected)

    return body_md

Integrate this function into your existing render script by calling it on the markdown body before passing it to pandoc. For example, if your render script does:

body_html = markdown_to_html(body_md)

Change that to:

body_html = markdown_to_html(resolve_citations(body_md))

Verification

Create a test markdown file with a citekey:

Test citation: [^13]

Run your render script on it. The output HTML should contain a superscript footnote link pointing to a footnote entry at the bottom of the page with the formatted reference.

Step 5: Import Existing Footnotes into Zotero

If you have an essay with manually written [^N]: footnotes, you can import them into Zotero programmatically using Zotero’s local connector API. The script below parses your footnotes, converts them to CSL-JSON, posts them to Zotero via the API, and writes a mapping file for the next step.

Save the following as ~/av/bin/footnote-to-zotero.py:

#!/usr/bin/env python3
"""Parse [^N]: footnotes from an archerships essay and import them into Zotero
via its local connector API.

Usage:
  python3 footnote-to-zotero.py path/to/essay.md
"""

import json, re, requests
from pathlib import Path

ZOTERO_API = 'http://127.0.0.1:23119/connector/saveItems'
OUTPUT_DIR = Path.home() / 'Zotero' / 'locate'
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

TYPE_MAP = {
    'article-journal': 'journalArticle', 'book': 'book',
    'report': 'report', 'webpage': 'webpage',
}

def parse_footnotes(md_path):
    text = md_path.read_text(encoding='utf-8')
    if text.startswith('---'):
        parts = text.split('---', 2)
        text = parts[2] if len(parts) >= 3 else text
    footnotes = []
    for m in re.finditer(r'\[\^(\d+)\]:\s*(.*?)(?=\n\[\^\d+\]:|\Z)', text, re.DOTALL):
        footnotes.append((int(m.group(1)), m.group(2).strip()))
    return sorted(footnotes, key=lambda x: x[0])

def extract_url(body):
    m = re.search(r'https?://\S+', body)
    return m.group(0).rstrip('.,;:)') if m else ''

def extract_year(body):
    m = re.search(r'\b(19\d{2}|20\d{2})\b', body)
    return m.group(1) if m else ''

def extract_title(body):
    m = re.search(r'"(.+?)"', body)
    return m.group(1) if m else ''

def detect_type(body):
    bl = body.lower()
    if re.search(r'\(.*(?:Press|University).*\)', body): return 'book'
    if any(x in bl for x in ['working paper','nber','brief']): return 'report'
    if any(x in bl for x in ['journal of','law review','review']): return 'article-journal'
    return 'webpage'

def to_zotero_item(fn_num, body):
    url = extract_url(body)
    title = extract_title(body)
    year = extract_year(body)
    etype = detect_type(body)
    ztype = TYPE_MAP.get(etype, 'webpage')
    author_text = re.match(r'^([^,"]+?)(?:,| "|\.)', body)
    author_text = author_text.group(1).strip() if author_text else ''

    creators = []
    for part in re.split(r'\s+and\s+', re.sub(r'\bet al\.?', '', author_text).strip(',; ')):
        if not part: continue
        if ',' in part:
            last, first = part.split(',', 1)
            creators.append({"creatorType":"author","lastName":last.strip(),"firstName":first.strip()})
        else:
            creators.append({"creatorType":"author","name":part})

    if not creators:
        creators.append({"creatorType":"author","name":author_text or "Unknown"})

    item = {"itemType": ztype, "title": title, "creators": creators, "url": url}
    if year: item["date"] = str(year)
    if ztype == 'journalArticle':
        m = re.search(r'\*([^*]+)\*', body)
        if m: item["publicationTitle"] = m.group(1)
    elif ztype in ('book', 'report'):
        m = re.search(r'\(([^)]+(?:Press|University|Institute|Center)[^)]*)\)', body)
        if m: item["publisher"] = m.group(1)
    return item

def main():
    import argparse
    ap = argparse.ArgumentParser()
    ap.add_argument('md_file')
    ap.add_argument('--dry-run', action='store_true')
    args = ap.parse_args()

    md_path = Path(args.md_file).resolve()
    footnotes = parse_footnotes(md_path)
    print(f'Found {len(footnotes)} footnotes.')

    items = [to_zotero_item(num, body) for num, body in footnotes]
    for num, body in footnotes:
        print(f'  [{num:2d}] {extract_title(re.sub(r"\\s+"," ",body).strip())[:50]}')

    if args.dry_run:
        print(f'\nDry run: would import {len(items)} items.')
        return

    ok = err = 0
    for i in range(0, len(items), 10):
        batch = items[i:i+10]
        try:
            r = requests.post(ZOTERO_API, json={'items': batch}, timeout=10)
            if r.status_code == 201: ok += len(batch)
            else: err += len(batch)
        except Exception as e:
            err += len(batch)

    print(f'\nImported: {ok}, errors: {err}')

    # Write mapping template
    slug = md_path.parent.name
    mapping_path = OUTPUT_DIR / f'{slug}-footnotes.mapping.txt'
    with open(mapping_path, 'w') as f:
        for num, body in footnotes:
            f.write(f'[^{num}] -> # {re.sub("\\s+"," ",body)[:80]}\n')
    print(f'Mapping template: {mapping_path}')

if __name__ == '__main__':
    main()

Run it:

python3 footnote-to-zotero.py path/to/essay.md

Zotero must be running and the Zotero Connector browser extension must be installed for the API to be available. The script posts items in batches of 10 to the local API at http://127.0.0.1:23119/connector/saveItems.

Step 6: Map [^N] Markers to 1

After the import completes, Better BibTeX auto-exports your .bib file with citekeys. The script below reads the .bib file and the essay, then replaces each [^N] with a [^15] by matching the footnote text against the .bib entries. It also removes the Notes section.

Save the following as ~/av/bin/replace-footnotes-with-citekeys.py:

#!/usr/bin/env python3
"""Replace [^N] markers with [^16] in an archerships essay.

Reads the essay's [^N] footnotes, matches them against entries in the
Zotero .bib file by comparing text fields, and rewrites the essay with
[^17] references. Removes the Notes section after replacement.

Usage:
  python3 replace-footnotes-with-citekeys.py path/to/essay.md [--dry-run]
"""

import re, argparse
from pathlib import Path
from difflib import SequenceMatcher

BIB_FILE = Path.home() / 'Zotero' / 'bibliography.bib'


def parse_bib_entries():
    """Return {citekey: {field: value}} from the .bib file."""
    if not BIB_FILE.exists():
        print(f'Error: {BIB_FILE} not found. Run the Zotero import first.')
        return {}

    bib_text = BIB_FILE.read_text(encoding='utf-8')
    entries = {}

    for m in re.finditer(r'@(\w+)\s*\{\s*([^,]+)\s*,', bib_text):
        entry_type = m.group(1)
        citekey = m.group(2).strip()
        start = m.end()
        depth = 1
        pos = start
        while pos < len(bib_text) and depth > 0:
            if bib_text[pos] == '{': depth += 1
            elif bib_text[pos] == '}': depth -= 1
            pos += 1
        entry_body = bib_text[start:pos - 1] if depth == 0 else bib_text[start:]

        fields = {}
        for line in entry_body.split('\n'):
            fm = re.match(r'^\s*(\w[\w-]*)\s*=\s*\{', line)
            if fm:
                fname = fm.group(1).lower()
                fstart = fm.end()
                fdepth = 1
                fpos = fstart
                while fpos < len(line) and fdepth > 0:
                    if line[fpos] == '{': fdepth += 1
                    elif line[fpos] == '}': fdepth -= 1
                    fpos += 1
                fval = line[fstart:fpos - 1].strip() if fdepth == 0 else line[fstart:].strip()
                if fval:
                    fields[fname] = fval.replace('{', '').replace('}', '')

        fields['_citekey'] = citekey
        fields['_type'] = entry_type
        entries[citekey] = fields

    return entries


def parse_footnotes(text):
    """Return [(number, body_text)] from the essay markdown."""
    footnotes = []
    for m in re.finditer(r'\[\^(\d+)\]:\s*(.*?)(?=\n\[\^\d+\]:|\n---|\Z)', text, re.DOTALL):
        footnotes.append((int(m.group(1)), m.group(2).strip()))
    return sorted(footnotes, key=lambda x: x[0])


def score_match(fn_body: str, fields: dict) -> float:
    """Score how well a footnote body matches a bib entry (0 to 1)."""
    scores = []
    fn_lower = fn_body.lower()

    # Title match (most reliable)
    bib_title = fields.get('title', '').lower()
    if bib_title:
        # Check how much of the title appears in the footnote
        ratio = SequenceMatcher(None, bib_title, fn_lower).ratio()
        scores.append(ratio * 3)  # title heavily weighted

    # Author match
    bib_author = fields.get('author', '').lower()
    if bib_author:
        # Extract first author surname
        auth_match = re.match(r'([^,]+)', bib_author)
        if auth_match:
            surname = auth_match.group(1).strip()
            if surname in fn_lower:
                scores.append(2.0)
            else:
                scores.append(0.0)

    # Journal/publisher match
    for fname in ('journal', 'booktitle', 'publisher'):
        val = fields.get(fname, '').lower()
        if val and val[:30] in fn_lower:
            scores.append(1.5)

    # URL match (definitive)
    bib_url = fields.get('url', '').lower().rstrip('/')
    if bib_url and bib_url in fn_lower:
        return 10.0  # URL match is conclusive

    if not scores:
        return 0.0
    return sum(scores) / len(scores)


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument('md_file', help='Path to the essay .md file')
    ap.add_argument('--dry-run', action='store_true',
                    help='Show what would change without modifying the file')
    args = ap.parse_args()

    md_path = Path(args.md_file).resolve()
    if not md_path.exists():
        ap.error(f'File not found: {md_path}')

    # Parse bib
    entries = parse_bib_entries()
    if not entries:
        return

    # Read essay
    text = md_path.read_text(encoding='utf-8')

    # Split frontmatter
    fm_sep = ''
    body = text
    if text.startswith('---'):
        parts = text.split('---', 2)
        if len(parts) >= 3:
            fm_sep = '---'
            body = parts[2]

    # Parse footnotes from the body
    footnotes = parse_footnotes(body)
    if not footnotes:
        print('No [^N]: footnotes found.')
        return

    print(f'Found {len(footnotes)} footnotes to map.')

    # Match each footnote to a bib entry
    # First pass: URL match (exact)
    fn_to_citekey = {}
    unmatched = []

    for num, fn_body in footnotes:
        fn_url = ''
        url_m = re.search(r'https?://\S+', fn_body)
        if url_m:
            fn_url = url_m.group(0).rstrip('.,;:)')

        best_key = None
        best_score = 0
        for ck, fields in entries.items():
            s = score_match(fn_body, fields)
            if s > best_score:
                best_score = s
                best_key = ck

        if best_key and best_score >= 2.0:
            fn_to_citekey[num] = best_key
        else:
            unmatched.append((num, fn_body, best_key, best_score))

    # Report
    matched_count = len(fn_to_citekey)
    print(f'Matched: {matched_count}, unmatched: {len(unmatched)}')
    for num, body, best, score in unmatched:
        fn_title = re.match(r'^[^,]+', body)
        fn_title = fn_title.group(0) if fn_title else body[:40]
        best_str = best or '(none)'
        print(f'  [?] [{num:2d}] score={score:.1f} best={best_str} -> {fn_title[:50]}')

    if args.dry_run:
        return

    # Replace [^N] in body with [^18]
    # Order by descending N so replacements don't shift later references
    for num in sorted(fn_to_citekey.keys(), reverse=True):
        citekey = fn_to_citekey[num]
        # Replace [^num] with [^19]
        body = re.sub(
            rf'\[\^{num}\]',
            f'[^20]',
            body
        )

    # Remove the Notes section
    body = re.sub(r'\n---\n\n## Notes\n\n.*?(\n---|\Z)', r'\1', body, flags=re.DOTALL)
    body = body.strip() + '\n'

    # Write back
    new_text = fm_sep + '\n' + body if fm_sep else body
    md_path.write_text(new_text, encoding='utf-8')
    print(f'\nWritten to {md_path}')
    print(f'Replaced {matched_count} footnote markers with [^21] references.')


if __name__ == '__main__':
    main()

Run it:

python3 replace-footnotes-with-citekeys.py path/to/essay.md [--dry-run]

Use --dry-run first to see which footnotes matched and which need manual attention. The script uses a scoring system: a URL match is conclusive (score 10.0), title and author matches contribute to a lower threshold. Footnotes below the threshold are listed as unmatched for manual review.

Verification

After running the mapping script, open the essay in Neovim. You should see [^22] patterns throughout the text instead of [^N] markers. Place your cursor on one and press <leader>zi – zotcite should display the citation info. Press <leader>zo to open the original source if Zotero has an attachment.

Run the render script:

python3 render-post.py path/to/essay.md

Open the resulting HTML in a browser. The footnote links should point to correctly formatted references at the bottom of the page.

Troubleshooting

Problem Likely Cause Fix
Zotero API returns 404 Zotero Connector not installed Install the browser extension, make sure it is active
[^N] markers still in output resolve_citations() not called Check that your render script invokes it before pandoc
citekey not found in .bib Better BibTeX auto-export not running Re-export with “Keep updated” checked
<C-X><C-B> does nothing in nvim File not recognized as markdown Run :set ft=markdown in the buffer
Footnote text has {{ curly braces Better BibTeX uses braces for case preservation Strip { and } from the resolved citation text
Could not find 'bibliography' field in YAML header on save zotcite needs the field in file frontmatter Add bibliography: ~/Zotero/bibliography.bib to the essay’s YAML frontmatter
HTML renders --- sections as narrow <table> instead of <hr> Pandoc 3.x interprets --- followed by text without a blank line as a simple table Always leave a blank line after --- thematic breaks, or use markdown+raw_html-yaml_metadata_block as the pandoc format
pandoc: YAML parse exception with --- in the body Pandoc 3.x tries to parse YAML metadata blocks mid-document Use --from markdown+raw_html-yaml_metadata_block in the pandoc command to disable YAML parsing

Notes

The scripts above are available at ~/av/bin/footnote-to-zotero.py and ~/av/bin/replace-footnotes-with-citekeys.py. The extended render script is at ~/av/bin/archerships/render-post.py.


Notes


  1. citekey (not found in bibliography)↩︎


Want to stay in touch?

Support my work