multi-site cms with payload: iaeste spain's website and one for every local committee


iaeste is a student network that finds paid internships abroad for students of engineering, science and other technical degrees. in spain it runs on local committees: groups of student volunteers, one per university. i built its new website: one installation that serves the national site and a site for every committee that wants one, all edited from a cms without touching code.
it hasn't replaced the old site on iaeste.es yet. in the meantime you can see it at iaeste-spain.joeltaylor.business, and the lleida committee's site at lleida.iaeste-spain.joeltaylor.business.
why a website per committee
someone searching for "iaeste" and someone searching for "iaeste lleida" or "internships abroad udl" want different things. the first wants to know what the programme is; the second wants to know where the committee's office is, when the next talk is and who has gone abroad from their university. a single national site answers the first search well and the second badly.
so the idea is many sites instead of one: the national one for general searches, and one per committee for searches about its city and university. each publishes its own blog, events and experiences. the risk is that every committee ends up with a different site, built by whoever was around that year and abandoned when they leave. to avoid that, all of them come from the same code and the same cms.


all the content, from the cms
the project is a monorepo with three apps: the website, in next.js 16; the cms, in payload 3; and a small hono api for forms and email. each has its own postgres database, and they deploy as three docker images on a self-hosted server with coolify.
everything you see on the site lives in the cms: news, events, student experiences, faqs, the team, the committees with their universities, and the photos. the national pages (home, students, companies, universities…) have fixed fields rather than a free-form block builder, so the marketing team can change the copy without being able to break the design.
what makes it usable for people who don't code:
- drafts save themselves every 2 seconds, and publishing can be scheduled.
- the live preview shows the draft inside the real site, at 390, 820 and 1,440 px wide.
- on publish, the cms tells the site to rebuild only the pages it affects, with no full rebuild.
- every text exists in spanish, english and catalan, and a side panel shows how much of each page is missing in each language.
- permissions are by role. a national editor can change anything; a committee editor only sees their committee's documents, and the field saying which committee a document belongs to is set automatically and can't be changed by them.
for the team there are 14 step-by-step guides, with 111 screenshots that a script retakes whenever the cms changes.
a website for every local committee, without code
creating a committee's site means filling in one document in the cms. the national office goes to «webs de comités», creates the committee's site, ticks «web activada» and publishes. from then on the committee writes its own copy with its own account. there's no deploy per committee: the whole network is one app.
the committee's site lives on a subdomain we give it, like lleida.iaeste.es. the committee doesn't need to buy a domain, although if it already has one, like iaestelleida.cat, i'd recommend using it, and it can point it at the same site.


the request's host decides which site you get. proxy.ts, which is what next.js 16 now calls middleware, reads the host, works out the committee from it (siteKeyFromHost) and rewrites the path to /[site]/[locale]/…. so to next.js each site has a different route, and each is cached separately. if someone visits the subdomain of a committee with no site, they land on the committee finder.
the shared design comes from limiting what can change. a committee's home and pages are built from 14 section types: figures, upcoming events, latest news, experiences, team, contact, form, how the exchange works, call to action, cards, text and image, photo carousel, logos and quote. the only visual choice is which side the image goes on and whether the background is light or navy. no colours, no fonts, no css.

a few more rules keep it tidy: at most one figures section and one form per page, at most two custom pages in the menu, and a new page can't take the address of a fixed one. the typescript compiler complains if someone adds a section type and forgets to render it.
seo across many sites at once
with every committee publishing on its own site, the problem is google seeing the same post in two places and not knowing which to show. the rule is that every document has exactly one home:
- a post, event or experience from a committee with a site lives on the committee's site.
- if a national editor ticks «mostrar también en iaeste.es» (also show on iaeste.es), it shows on the national site too, but the link goes to the committee's site and the canonical tag points there.
- opening that document on the other site redirects to its home.
- the sitemap, robots.txt and llms.txt are per host, and each site lists only its own documents.
the rest is what i'd do on any site, but for every site and every language:
- paths and slugs are translated (
/es/estudiantes,/en/students,/ca/estudiants), withhreflangfor each language andx-defaultin spanish. - structured data in json-ld:
NGOon every page, with the committee as part of iaeste spain and iaeste spain as part of iaeste, plusArticle,EventandFAQPage. - every page has its own share image, drawn from the page's title and photo and recompressed to stay under the 300 kb that whatsapp and linkedin accept.
- every page also exists as markdown, by adding
.mdto the url, for ai assistants. - the old wordpress site's urls redirect to the new ones with a 301.
- a script,
check:seo, crawls a live site's sitemap and checks titles, descriptions, canonicals, thathreflanglinks point both ways, share images and json-ld.
when your university has no iaeste
of the 12 committees on the list, only 7 are active today. a lot of people searching for iaeste study at a university that has none. the site treats them as possible founders of a new committee.

this is the path:
- in the committee finder, a search with no results shows «no committee in your city?» with what you searched for. the finder works without javascript too, because it filters on the server.
- the button scrolls to the form with «another university» already picked, and one of the interests is «join a committee or start one».
- the api checks whether the university you typed belongs to an active committee. if it does, it notifies that committee; if not, the contact goes to the national office.
- in the cms, the national office has a report, «universidades sin comité» (universities without a committee), with contacts grouped by university. it's there to decide where to open the next committee.

on top of that, analytics record every finder search with its number of results, and the ones with zero results show which cities are looking for iaeste and not finding it.
the next step is a page for every university without a committee, so that someone searching for "iaeste" and their university's name finds a page that tells them there isn't one yet and how to start it.
what surprised me
- university and company mail filters open every link in a message to check it. if confirming a subscription were just opening the link, the filter would confirm everyone. the confirmation is a post: the page sends it on its own with javascript, which a filter doesn't run, and without javascript there's a button.
- the form limit is 60 per ip address every 15 minutes, because at a talk at the university a whole lecture hall sends the form from the same network.
- satori, which draws the share images, can't read the webp images the cms serves, and every card with a photo returned a 500. now the photo is cropped and turned into a jpeg with sharp first.
- the cms docker image weighs about 1.7 gb and the website's about 400 mb: next.js's standalone mode leaves out the
payload migratecommand, which is what applies the migrations before startup. - the form's validation, with zod, only loads when someone starts filling it in, which took about 90 kb of compressed javascript off the pages that have it.
in numbers
- 7 active committees out of 12 on the list, in 3 languages, with 14 section types.
- about 41,000 lines of typescript, not counting generated code or migrations: 17,800 in the website, 12,100 in the cms, 2,900 in the api and 7,800 in the shared packages.
- 452 tests in 67 files, which run on every change against a real postgres database.
counted on the repository on 12 october 2026.