Malton London Press
LiveThe website and publishing system of an open-access academic publisher. I built it, I run the server, and I deploy every change.
- My role
- Sole developer
- Year
- 2026
- Built with
- Next.js · Payload CMS · PostgreSQL · Turborepo · PM2 · Nginx · Hetzner · GitHub Actions
Scroll sideways to see the whole diagram.
The problem
An academic publisher needs more than a website. Every book and conference paper has to be free to read and download, editors have to publish without touching code, and Google Scholar has to find and index each paper. If it doesn't, the authors' work stays invisible.
How it is built
A pnpm and Turborepo monorepo with two apps and a shared package: a Next.js site for readers, and Payload CMS on PostgreSQL, where the editors work and the site gets its content. Readers browse books and conference proceedings and download the PDFs, and authors send book proposals through a form. Everything runs on one Hetzner server behind Nginx, with PM2 keeping both apps in cluster mode. Every push runs lint, type checks and tests in GitHub Actions. A deploy is started by hand: the workflow runs the checks again on the exact commit, connects to the server over SSH, runs the same deploy script a person would run (install, migrate, build, reload) and then polls a health endpoint from outside until the site answers.
Decisions that mattered
Deploys only start by hand
Nothing deploys on push, so a bad commit on main cannot reach production by itself. The workflow calls the same script a person would run, so the manual and automated paths cannot drift apart.
No automatic rollback
Checking out old code does not undo a database migration that has already run, so going back stays a deliberate step. The previous commit is saved on the server before every deploy, which makes the rollback one command.
Metadata Google Scholar can read
Every paper and book page carries Highwire Press citation tags, with each author followed by their own institution and ORCID, so Google Scholar and reference managers like Zotero index them correctly.
Code from the repo
/** One citation_author per person, each followed by its own institution and ORCID. */
function authorTags(people: Person[] | null | undefined): CitationTag[] {
const tags: CitationTag[] = []
for (const person of people ?? []) {
const name = person.name?.trim()
if (!name) continue
tags.push(['citation_author', name])
const institution = person.affiliation?.trim()
if (institution) tags.push(['citation_author_institution', institution])1
const orcid = orcidUrl(person.orcid)
if (orcid) tags.push(['citation_author_orcid', orcid])
}
return tags
}
// …
/** Tags for a conference paper page (/proceedings/[volume]/[paper]). */
export function paperCitationTags(
paper: Pick<Paper, 'title' | 'slug' | 'authors' | 'doi' | 'pdf' | 'pagesRange'>,
volume: Pick<Book, 'title' | 'slug' | 'publicationDate' | 'isbn' | 'conference'>,
siteUrl: string = publicSiteUrl(),
): CitationTag[] {
const tags: CitationTag[] = []
add(tags, 'citation_title', paper.title)
tags.push(...authorTags(paper.authors))
add(tags, 'citation_publication_date', citationDate(volume.publicationDate))2
// …
const site = siteUrl.replace(/\/+$/, '')
// Same host and directory as this page (Scholar ignores a PDF anywhere else).
if (hasFile(paper.pdf)) add(tags, 'citation_pdf_url', `${site}${paperPdfPath(volume.slug, paper.slug)}`)3
add(tags, 'citation_abstract_html_url', `${site}/proceedings/${volume.slug}/${paper.slug}`)
add(tags, 'citation_language', 'en')
return tags
}
Line 9. Scholar links an institution and an ORCID to the author tag right before it, so the order of these tags carries meaning.
Line 27. Dates go out as YYYY/MM/DD in UTC, the form Scholar asks for.
Line 31. Scholar only accepts a PDF from the same host and directory as the paper's page, so the link is built from the page's own path.
Where it is now
Live since January 2026 at maltonlondonpress.com. I keep it running and deploy every change.