- Restore the SHARP-ER in the homepage hero section - Match the design for the 3-image section in the homepage
SHARP-ER website
Bilingual (English / Arabic) website for SHARP-ER — Shared Action on Rule of Law Progress – Exit Route — a European Union funded programme running nationwide in Libya from December 2025 to December 2027.
SHARP-ER supports Libyan civil society organisations, local institutions and communities working on justice, accountability and governance. It is implemented by a consortium of Acted (lead), United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and continues the work of SHARP I and SHARP II (2019–2024).
The site publishes project information, events and registrations, calls and applications, news from the project and its partner network, and a knowledge hub of resources and publications — all of it in English and Arabic, with the Arabic side laid out right to left.
EU visibility is a contractual requirement: the Funded by the European Union emblem is present on every page, and the three implementing partners are always shown together.
Contents
- The stack, and why
- Repository layout
- Choosing a setup path
- Path A — manual setup
- Path B — Docker
- Available scripts
- Seeding content
- Design tokens
- Bilingual EN / AR
- Adding a content type, end to end
- Fonts (licensed, not in this repository)
- Deployment and operations
- Troubleshooting
- Source documents
The stack, and why
| Layer | Choice | Why |
|---|---|---|
| Front end | React Router 8, framework mode, ssr: true |
The terms of reference require an SEO-friendly structure. Server-side rendering means every page — including the Arabic one — is delivered as complete HTML with the correct <html lang> and <html dir> in the first byte, plus canonical and hreflang tags a crawler can read without executing JavaScript. |
| UI | React 19, TypeScript, Vite 8 | The framework's own toolchain. Typed loaders and route modules, so a content shape change is a compile error rather than a blank page. |
| Styling | Tailwind CSS 4 over a two-tier design-token layer | Tier 1 holds raw values, tier 2 holds meanings; components only ever spend tier‑2 tokens. Re-branding is a one-line edit in one file. See Design tokens. |
| CMS | Strapi 5.53 (TypeScript) | Self-hosted and handed over to UEC at the end of the assignment, as the ToR requires. Its i18n plugin models EN/AR as first-class locales of the same entry, and draft & publish gives editors a review step. |
| Database | PostgreSQL 16 | Strapi's recommended production database; proper UTF‑8 and JSON support for bilingual content. |
| Runtime | Node 22, pnpm 9 workspace | One lockfile, two applications, one pnpm install. |
The web application never talks to the database. It reads the Strapi REST API from its loaders on the server, so the API token never reaches the browser, and every page falls back to bundled draft copy if the CMS is unreachable — the site stays up when Strapi is down.
Repository layout
sharp-er/
├── apps/
│ ├── web/ React Router 8 front end (SSR)
│ │ ├── app/
│ │ │ ├── root.tsx HTML shell: <html lang/dir>, canonical + hreflang, nav, footer
│ │ │ ├── routes.ts Route config: "/" redirect, ":lang" layout, the ten sections
│ │ │ ├── routes/ One module per page (+ shared _page-kit / _shared helpers)
│ │ │ ├── components/ui/ Button, Input, Card, Navigation, Footer, LanguageSwitcher
│ │ │ ├── styles/ Design tokens — primitives.css, semantic.css, typography.css
│ │ │ ├── i18n/ Locale config, message catalogues (en.json / ar.json), formatters
│ │ │ └── lib/ strapi.ts (API client), seo.ts, env.server.ts, route-slugs.ts
│ │ ├── public/
│ │ │ ├── assets/ Logos, EU emblem, partner logos, icons
│ │ │ └── fonts/ Licensed woff2 files — NOT committed, see FONTS below
│ │ └── Dockerfile
│ │
│ └── cms/ Strapi 5 CMS
│ ├── config/ server, admin, database, middlewares (CORS), plugins (i18n, upload)
│ ├── src/
│ │ ├── api/ One folder per content type (schema, controller, routes, service)
│ │ ├── components/ Reusable field groups (hero, cta, seo, blocks/*)
│ │ └── bootstrap/ Boot-time reconciliation: locales + public permissions
│ ├── .env.example Every Strapi variable, documented
│ └── Dockerfile
│
├── docs/ SOURCE MATERIAL — read only, never edited
├── scripts/ setup-db.sh, generate-secrets.sh, check-prereqs.sh
├── docker-compose.yml postgres + cms + web
├── .env.example Root template: docker compose and the web server read this
├── CLAUDE.md Working brief for AI agent sessions
└── pnpm-workspace.yaml
Choosing a setup path
Both paths give you the same three processes. Pick one; you do not need both.
| Path A — manual | Path B — Docker | |
|---|---|---|
| You install | Node 22, pnpm 9, PostgreSQL 16 | Docker Engine + Compose v2 |
| Best for | day-to-day development (hot reload, fast rebuilds) | trying the project out, review builds, servers |
| Web | http://localhost:5173 (dev server) |
http://localhost:3000 (production build) |
| CMS | http://localhost:1337/admin |
http://localhost:1337/admin |
| Database | your local PostgreSQL, port 5432 | container, published on port 5433 |
| Time to first run | ~5 minutes | ~10 minutes (image build) |
Check your machine before you start:
./scripts/check-prereqs.sh # both paths
./scripts/check-prereqs.sh manual # or just one
./scripts/check-prereqs.sh docker
Path A — manual setup
1. Prerequisites
| Tool | Version | Notes |
|---|---|---|
| Node.js | >= 22.22.0 | React Router 8 will not run on anything older — this is a hard floor, not a recommendation. .nvmrc pins 22.23.2, the version this repository is developed on. Strapi 5 accepts Node 20 – 26, so 22 satisfies both. |
| pnpm | 9.x (repo pins pnpm@9.12.3) |
npm and yarn will not work: the workspace, the lockfile and the hoisted node_modules layout in .npmrc are all pnpm's. |
| PostgreSQL | 16 (server + client tools) | Client tools give you psql, createdb and pg_isready. |
| Git | any recent version |
# Node — with nvm, the pinned version is one command
nvm install "$(cat .nvmrc)" && nvm use
# pnpm — corepack ships with Node
corepack enable && corepack prepare pnpm@9.12.3 --activate
# PostgreSQL 16 on Debian / Ubuntu
sudo apt install postgresql-16 postgresql-client-16
sudo systemctl enable --now postgresql
# macOS with Homebrew
brew install node@22 pnpm postgresql@16 && brew services start postgresql@16
Verify: node -v → v22.22.0 or newer, pnpm -v → 9.x, psql --version → 16.x.
2. Install dependencies
git clone <repository-url> sharp-er
cd sharp-er
pnpm install # installs BOTH apps from the single root lockfile
Always run pnpm install from the repository root. .npmrc sets node-linker=hoisted
because Strapi's plugin resolution expects a flat node_modules; installing inside
apps/cms or apps/web on its own produces a tree neither application can boot from.
3. Create the database
The database is called sharper_cms and is owned by the role Strapi connects as
(postgres by default).
./scripts/setup-db.sh
The script is idempotent — it creates the database only if it is missing, never drops
anything, and finishes by proving it can connect and that the role may create tables.
It reads its defaults from .env and apps/cms/.env, and takes
--name, --user, --password, --host, --port overrides (--help for the details).
Doing it by hand is two commands:
createdb -h 127.0.0.1 -U postgres sharper_cms
psql -h 127.0.0.1 -U postgres -d sharper_cms -c "SELECT current_database(), version();"
If psql asks for a password you do not have (fresh Ubuntu install)
A newly installed PostgreSQL has a postgres role with no password set, reachable only
over the local socket. Set one, then use it as DATABASE_PASSWORD in apps/cms/.env:
sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';"
For a shared or production machine, create a dedicated role instead:
sudo -u postgres psql -c "CREATE ROLE sharper WITH LOGIN PASSWORD 'a-strong-password' CREATEDB;"
./scripts/setup-db.sh --user sharper --password 'a-strong-password'
4. Environment files
Three files, with different jobs:
| File | Read by | Required in manual mode |
|---|---|---|
apps/cms/.env |
Strapi (it loads its own .env) |
yes |
apps/web/.env |
pnpm --filter web start (it loads its own .env) |
no — but it is where PORT goes |
.env (root) |
docker compose only |
no |
Each application reads its own .env, and the root one belongs to docker compose.
That split is not cosmetic: PORT and HOST mean something different to each server, and a
single shared value would put Strapi on the website's port.
# CMS: copy the fully documented template, then fill in the six secrets
cp apps/cms/.env.example apps/cms/.env
./scripts/generate-secrets.sh --write apps/cms/.env
# Set the database credentials you used in step 3
# DATABASE_CLIENT=postgres
# DATABASE_HOST=127.0.0.1
# DATABASE_PORT=5432
# DATABASE_NAME=sharper_cms
# DATABASE_USERNAME=postgres
# DATABASE_PASSWORD=...
scripts/generate-secrets.sh fills APP_KEYS, API_TOKEN_SALT, ADMIN_JWT_SECRET,
JWT_SECRET, TRANSFER_TOKEN_SALT and ENCRYPTION_KEY with 32 random bytes each. It never
overwrites a value that is already set unless you pass --force, so it is safe to re-run.
Without --write it just prints a block you can paste. By hand:
openssl rand -base64 32, once per key (APP_KEYS takes at least two, comma separated).
The web application needs nothing to start: STRAPI_URL already defaults to
http://localhost:1337, and with SITE_URL unset the canonical URL is derived from the
incoming request. Copy its template when you want to override those, or to pin the
production port:
cp apps/web/.env.example apps/web/.env # PORT, HOST, SITE_URL, STRAPI_URL, STRAPI_TOKEN
pnpm --filter web start loads that file. pnpm dev:web does not — the dev server is Vite
on 5173 — so export anything you need for development, or put it in the file for the
production server and pass it inline while developing.
5. Run both applications
pnpm dev # web + cms together, output interleaved and prefixed
or, in two terminals:
pnpm dev:cms # Strapi -> http://localhost:1337
pnpm dev:web # React Router -> http://localhost:5173
The first pnpm dev:cms takes a minute: Strapi creates its tables, then the boot-time
reconciliation in apps/cms/src/bootstrap/ runs. That step is idempotent and executes on
every boot; it
- creates the
en(default) andarlocales if they are missing, and - grants the Public role
find/findOneon the content types the site reads, andcreate— and onlycreate— on the three submission types (event registrations, applications, contact messages), so nobody can read other people's personal data back out.
You never have to click through the Users & Permissions screens after adding a content type; restart the CMS and the grant is there.
6. Create the first admin user
Strapi has no seeded administrator. On first run, open
and complete the registration form — the first account created becomes the super admin. Or do it from the command line:
pnpm --filter cms exec strapi admin:create-user \
--firstname="Site" --lastname="Admin" \
--email="admin@example.org" --password="SomeStrongPassw0rd"
Passwords must be at least 8 characters and contain a number and an uppercase letter.
7. Confirm it works
| URL | Expected |
|---|---|
http://localhost:5173/ |
302 to /en or /ar depending on your Accept-Language |
http://localhost:5173/en |
English home page, <html lang="en" dir="ltr"> |
http://localhost:5173/ar |
Arabic home page, <html lang="ar" dir="rtl">, mirrored layout |
http://localhost:1337/admin |
Strapi admin panel |
http://localhost:1337/api/home-page?locale=en |
JSON (or 404 until an entry is published) |
http://localhost:1337/_health |
204 No Content |
# The one check worth automating: is the SSR HTML right in the first byte?
curl -s http://localhost:5173/ar | head -c 200
Then run the type checker once, so you know the baseline is clean:
pnpm typecheck
Path B — Docker
Three containers: postgres (database), cms (Strapi), web (React Router). Nothing else is installed on your machine — no Node, no pnpm, no PostgreSQL.
1. Prerequisites
- Docker Engine 24+ (or Docker Desktop) with the Compose v2 plugin.
Check with
docker compose version; the standalonedocker-composebinary is not supported — this file usesdepends_on.condition, which is Compose v2 syntax. - Roughly 3 GB of disk for the images and 2 GB of RAM while they build.
2. Configure
cp .env.example .env
./scripts/generate-secrets.sh --write .env
docker compose reads the .env file sitting next to docker-compose.yml and substitutes
every ${VARIABLE} in it. The six Strapi secrets are declared as ${VAR:?...}, so a missing
value stops the stack straight away with a readable message instead of a Strapi crash loop.
In Docker mode apps/cms/.env is not used — the container receives its environment from
docker-compose.yml. The root .env is the only file you edit.
3. Start
docker compose up -d --build # first run: builds both images, ~5-10 minutes
docker compose ps # STATUS column shows (healthy) when each is up
Start-up is ordered by health, not by luck:
postgres healthy -> cms healthy -> web
pg_isready GET /_health GET /en
cms waits for a database that is actually accepting connections (pg_isready), and web
waits for a CMS that answers /_health, so the very first page render already has content.
The health checks themselves are declared in apps/cms/Dockerfile and apps/web/Dockerfile,
which means docker run and any orchestrator inherit them too.
Give the CMS up to 90 seconds on its first boot: it creates its tables, then runs the locale and permission reconciliation described in Path A, step 5.
docker compose logs -f # everything, following
docker compose logs -f cms # one service
docker compose logs --tail=50 web
4. Create the first admin user
Open http://localhost:1337/admin and register — the first account is the super admin. From the command line:
docker compose exec cms pnpm exec strapi admin:create-user \
--firstname="Site" --lastname="Admin" \
--email="admin@example.org" --password="SomeStrongPassw0rd"
5. Confirm it works
| URL | Expected |
|---|---|
http://localhost:3000/ |
302 to /en or /ar |
http://localhost:3000/en and /ar |
English and Arabic home pages |
http://localhost:1337/admin |
Strapi admin panel |
psql -h 127.0.0.1 -p 5433 -U postgres sharper_cms |
a database prompt |
What the compose file sets up
| Service | Image | Host port | Persists in | Health check |
|---|---|---|---|---|
postgres |
postgres:16-alpine |
5433 → 5432 | volume sharper_postgres-data |
pg_isready |
cms |
built from apps/cms/Dockerfile |
1337 | volume sharper_cms-uploads (Media Library) |
GET /_health |
web |
built from apps/web/Dockerfile |
3000 | nothing — stateless | GET /en |
The database is published on 5433 on purpose, so it cannot collide with a PostgreSQL
already listening on your host's 5432. Inside the compose network the CMS still reaches it as
postgres:5432. Change the published port with POSTGRES_PUBLISHED_PORT in .env.
Both images build from the repository root as their context, because the pnpm lockfile lives there. They are not size-optimised: the CMS image keeps its dev dependencies, because Strapi's CLI resolves its TypeScript toolchain when it starts.
Images from the CMS
Uploaded media is served by Strapi, and the web server turns a relative /uploads/x.jpg
into an absolute URL by prefixing STRAPI_URL. Inside Docker that value is http://cms:1337,
a hostname Docker resolves but your browser does not — so pictures uploaded through the Media
Library will not load until it can. One line in your hosts file fixes it, permanently:
echo "127.0.0.1 cms" | sudo tee -a /etc/hosts # Windows: C:\Windows\System32\drivers\etc\hosts
cms then resolves to 127.0.0.1, where compose publishes the CMS on port 1337, so the one
URL works from the browser and from inside the compose network alike. Everything else —
pages, text, layout, and the site's own images in apps/web/public/assets/ — works without
this step.
In production this problem disappears: the CMS has a real hostname
(https://cms.sharper.ly), and STRAPI_URL is set to it.
Everyday commands
docker compose ps # status and health
docker compose restart cms # restart one service
docker compose exec cms sh # shell inside the CMS container
docker compose exec postgres psql -U postgres sharper_cms
docker compose up -d --build web # rebuild after changing app code
docker compose build --no-cache cms # rebuild ignoring the layer cache
Source code is not bind-mounted: the images contain a production build, so a code change
needs a rebuild. For day-to-day development use Path A, or run only the database in Docker
(docker compose up -d postgres) and the two applications with pnpm dev.
Stopping and tearing down
| Command | Containers | Database + uploads |
|---|---|---|
docker compose stop |
stopped, kept | kept |
docker compose down |
removed | kept (named volumes survive) |
docker compose down -v |
removed | deleted — irreversible |
docker compose down --rmi local |
removed, images too | kept |
down -v is how you start from an empty database — every entry, every uploaded file and
the admin account are gone. Take a dump first if you are unsure:
# Back up
docker compose exec -T postgres pg_dump -U postgres sharper_cms > backup-$(date +%F).sql
# Restore into a fresh stack
docker compose exec -T postgres psql -U postgres sharper_cms < backup-2026-01-31.sql
# Uploaded media
docker run --rm -v sharper_cms-uploads:/data -v "$PWD":/backup alpine \
tar czf /backup/uploads-$(date +%F).tar.gz -C /data .
Available scripts
Repository root (package.json) — these fan out across the workspace:
| Command | Does |
|---|---|
pnpm dev |
runs web and cms in parallel, streaming both logs |
pnpm dev:web |
React Router dev server with HMR → http://localhost:5173 |
pnpm dev:cms |
strapi develop (auto-reloads on schema changes) → http://localhost:1337 |
pnpm build |
builds the CMS, then the web app (that order: the web build has no CMS dependency, but a broken CMS build should fail first) |
pnpm start |
runs both production builds |
pnpm seed |
writes the approved content into the CMS — see Seeding content |
pnpm typecheck |
tsc in both applications, preceded in the web app by react-router typegen |
apps/web — run with pnpm --filter web <script>:
| Command | Does |
|---|---|
dev |
react-router dev — SSR dev server with HMR |
build |
react-router build → build/client (static assets) + build/server (SSR bundle) |
start |
the production server — react-router-serve ./build/server/index.js, started with node --env-file-if-exists=.env so apps/web/.env can set PORT |
typecheck |
regenerates +types/* route types, then tsc |
apps/cms — run with pnpm --filter cms <script>:
| Command | Does |
|---|---|
develop / dev |
strapi develop — the only mode in which the Content-Type Builder can write schemas |
build |
builds the admin panel and compiles the TypeScript server into dist/ |
start |
strapi start — production mode; the Content-Type Builder is disabled |
seed |
writes the CMS content — pnpm --filter cms seed -- --demo to include the fixtures |
console |
a REPL with strapi in scope, for one-off data fixes |
strapi |
the CLI itself, e.g. pnpm --filter cms exec strapi admin:create-user |
upgrade / upgrade:dry |
@strapi/upgrade codemods for a minor/major version bump |
scripts/ — repository helpers, all read-only unless you pass --write:
| Script | Does |
|---|---|
./scripts/check-prereqs.sh [all|manual|docker] |
verifies Node, pnpm, PostgreSQL, Docker and the env files; exits non-zero if something required is missing |
./scripts/setup-db.sh |
idempotent createdb + connection/permission verification (Path A, step 3) |
./scripts/generate-secrets.sh [--write FILE] [--force] |
fresh Strapi secrets; fills only empty/placeholder keys unless forced |
Seeding content
A fresh database has an empty CMS, so every page renders its fallback copy and
every listing renders its empty state. pnpm seed fills it in. Both
applications can stay running while it does — it talks to PostgreSQL directly
and never opens a port of its own.
pnpm seed # the approved content
pnpm seed -- --demo # the same, plus fixtures to look at
pnpm seed -- --demo --clean # remove those fixtures again
The
--is pnpm's, not the seed's. Running it fromapps/cms(pnpm seed --demo) needs no separator.
It is safe to run twice. Every write is matched on a slug and written over
the same document, so a second run changes nothing but the fields the seed
owns — your own edits to other fields survive. It deletes nothing except on
--demo --clean. It uploads nothing: where a page already has a picture, the
existing file is read back and written out again with the new copy.
The approved set — apps/cms/src/seed/approved.ts
Transcribed from docs/, in both languages, and safe to publish: the site
settings, the three consortium members, the topic taxonomy, the five activity
streams, the launch article, and the About, What We Do, Contact and Home
pages.
What docs/ does not supply is not written. There are no statistics anywhere
(the source marks every number "TBD — must not be published until confirmed"),
no project email, no Facebook URL and no consortium addresses, because the
source writes all of those as bracketed placeholders. Two fields are judgement
calls and say so at the line: every activity's activityStatus, which the
schema requires and the source never states, and the launch article's year,
which is derived — the source says "Tuesday, June 30", and within the project's
own December 2025 – December 2027 window that is 2026 and no other year.
The demo set — apps/cms/src/seed/demo.ts
Invented content: twelve network organisations, six articles, six events, four opportunities, six resources, six knowledge items, eight questions and five regions, across the calendar and across every enum value, so the listings, filters, detail pages and the home page's logo marquee have something in them.
It is kept apart from real content three ways:
- Opt-in.
pnpm seednever writes it. - Refused in production. With
NODE_ENV=productionthe seed exits with an error rather than writing a fixture, whatever flags it is given. - Tagged in the data. Every demo slug starts
demo-, so it is visible in every URL —/en/news-and-updates/demo-…— and--demo --cleanfinds them again by that prefix alone, with nothing remembered between runs.
After seeding
curl -s "http://localhost:1337/api/news-articles?locale=ar" | head -c 200
Then the home page at http://localhost:5173/en and /ar: the hero carries the
CMS headline, the newsroom band has cards, and — with --demo — the network
logo wall becomes the marquee, which needs at least eight partners before it
stops being a grid.
Design tokens
Two tiers, so re-branding is a one-line edit in one file.
app/app.css entry: tailwindcss, then the three files below
app/styles/primitives.css TIER 1 raw values, no meaning (@theme)
app/styles/semantic.css TIER 2 roles that alias tier 1 (per surface)
app/styles/typography.css TIER 2 type roles, @font-face, EN/AR font switch
The one rule: components may reference only tier-2 tokens. Never a raw hex, never a tier-1 primitive.
<h2 className="heading-h2 text-[var(--text-primary)]" /> // yes
<h2 className="text-[#f8f9fa] text-[28px]" /> // no — hex and raw type
<h2 className="text-[var(--color-neutral-50)]" /> // no — tier-1 primitive
Because every component spends meanings rather than values, moving the brand green is four
lines in primitives.css (--color-green-600 and its hover / active / disabled steps) and
no component changes at all.
The home page is dark by default, while the navigation and footer also have light
variants, so the palette is modelled as surfaces, not a global theme: :root is the
dark surface, and any element can flip its own subtree with data-surface="light", which
re-resolves every tier-2 token and paints itself.
Brand colours are White #ffffff, Dark Grey #1D1D1B, Dark Turquoise #008764 and
Red #DD322F, taken from the communication guideline in docs/.
Full reference — every token family, the Tailwind bridge, the surface model, the type roles
and the one deliberate deviation from Figma: apps/web/app/styles/README.md.
Bilingual EN / AR
URLs carry an explicit locale prefix on both languages. There is no ?lang= parameter
anywhere in the product.
/ 302, Vary: Accept-Language -> /en or /ar
/en, /en/about, ... English, ltr
/ar, /ar/about, ... Arabic, rtl
/EN/about 301 -> /en/about
The slug itself is always the Latin one (/ar/knowledge-hub, not a transliterated path), so
a URL survives a language switch and analytics can aggregate the two.
<html lang> and <html dir> are correct in the first SSR byte — they are attributes of
the server-rendered document, not something JavaScript sets afterwards. root.tsx also emits
a canonical link and hreflang alternates for en, ar and x-default on every page.
Where the words live
| Content | Lives in | Edited by |
|---|---|---|
| UI strings (navigation, buttons, filters, form labels, error messages) | apps/web/app/i18n/messages/en.json and ar.json — identical key sets, enforced by the Messages type |
developers |
| Page and article content | Strapi, as an en and an ar version of the same entry |
editors |
| Draft fallback copy | apps/web/app/routes/_page-kit/copy.ts, _shared/page-copy.ts — the approved prose used when the CMS is empty or unreachable |
developers |
Read a string with the useTranslation() hook in a component, or translate(locale, key) in
a loader; keys are dotted paths (t("pagination.page", { page: 2, pageCount: 7 })) and are
typechecked against en.json, so a missing translation is a build error.
Right-to-left
RTL is not a stylesheet; it is a consequence of using CSS logical properties everywhere.
Write margin-inline-start, padding-inline, inset-inline-start, text-align: start,
border-inline-start — and in Tailwind the logical utilities ps- pe- ms- me-
start- end-, never pl- pr- ml- mr- left- right-. The Arabic layout then
mirrors for free. rtl: and ltr: variants exist for the rare case that genuinely cannot be
expressed logically — an asymmetric icon, a background position.
Dates and numbers go through ~/i18n (formatDate, formatDateRange, formatNumber,
formatFileSize), which uses en-GB and ar-LY with Western digits, matching the approved
Arabic copy.
Adding a third locale
The types are exhaustive Record<Locale, …> maps, so after step 1 the compiler lists every
place that still has to be filled in.
apps/web/app/components/ui/LanguageSwitcher/locale-path.ts— add the code toLOCALES, and an entry toDIRECTION_BY_LOCALE,LOCALE_ENDONYMandLOCALE_SHORT_LABEL.apps/web/app/i18n/config.ts— add it toINTL_LOCALEandOG_LOCALE.apps/web/app/i18n/messages/<code>.json— copyen.json, translate it, and register it ingetMessages().apps/cms/config/plugins.ts— add{ code, name }to thelocalesarray and restart the CMS;src/bootstrap/locales.tscreates it in the database on the next boot.- Nothing else: routing (
/:lang), Accept-Language negotiation, the navigation, the footer link columns and thehreflangalternates all derive fromSUPPORTED_LOCALESandROUTE_SLUGS.
Note that the language switcher's UI is built for exactly two languages (it renders the other one); a third locale needs it turned into a menu.
Adding a content type, end to end
Worked example: a Stories collection, listed at /en/stories and /ar/stories.
1. Model it in Strapi
Run the CMS in development mode (pnpm dev:cms) — the Content-Type Builder only writes
schemas in strapi develop. Create the collection type, then in Advanced settings enable
Internationalization and Draft & publish.
Strapi writes the result to apps/cms/src/api/story/ (content-types/story/schema.json,
plus a controller, routes and a service). Commit that folder — it is the schema, not a
local artefact. You can equally write the JSON by hand and restart.
Field conventions used across this project: title (string, required), slug
(uid from title, required), summary (text), body (blocks or rich text — both render),
cover (single media), publicationDate (date — the field everything sorts and filters on),
topics (relation to the shared topics taxonomy, so a tag means the same thing everywhere).
2. Restart the CMS, then check the API
apps/cms/src/bootstrap/public-permissions.ts grants the Public role find and findOne
on every api::* content type at boot, so there is nothing to click:
curl "http://localhost:1337/api/stories?locale=en&populate=cover"
A collection holding personal data is the exception — add its API name to
PUBLIC_SUBMISSION_CONTENT_TYPES in apps/cms/src/bootstrap/constants.ts and it gets
create only, never find.
3. Give it a URL
For a new top-level section, add the slug to apps/web/app/lib/route-slugs.ts
(ROUTE_SLUGS, and the right group in ROUTE_SECTION_GROUPS) and a label under
nav.sections in both app/i18n/messages/en.json and ar.json. The navigation, the
footer sitemap and sectionPath() all derive from those two, so nothing else needs editing.
Then register the module in apps/web/app/routes.ts. Section modules are picked up by their
conventional path (SECTION_MODULES); a detail route is one explicit line:
route("stories/:slug", "routes/stories.$slug.tsx", { id: "storyDetail" }),
Vite does not re-evaluate routes.ts when a new file appears — restart pnpm dev:web
after adding a route module.
4. Write the route module
apps/web/app/routes/stories.tsx. Fetch on the server, in the loader; the Strapi client is
server-only, and the API token never reaches the browser.
import { buildMeta } from "~/lib/seo";
import { getMany } from "~/lib/strapi";
import { readRootSeo, requireLocale } from "~/routes/_shared/listing";
import type { LoaderFunctionArgs, MetaArgs } from "react-router";
interface Story {
documentId: string;
title: string;
slug: string;
summary?: string;
publicationDate?: string;
}
export async function loader({ params }: LoaderFunctionArgs) {
const locale = requireLocale(params.lang); // 404s on anything but en | ar
const { data } = await getMany<Story>("stories", {
locale,
sort: "publicationDate:desc",
populate: { cover: { fields: ["url", "alternativeText"] } },
pagination: { page: 1, pageSize: 12 },
});
return { locale, stories: data };
}
export function meta({ matches }: MetaArgs) {
const root = readRootSeo(matches); // { locale, pathname, siteUrl }
return buildMeta({ ...root, title: "Stories", description: "…" });
}
export default function Stories({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>> }) {
return <main>{/* Card.Content, PageShell, … from the shared kit */}</main>;
}
For a detail page use requireEntity(await getBySlug<Story>("stories", params.slug, { locale }))
— requireEntity throws the 404 the root ErrorBoundary renders, localised.
Do not pass alternates: true to buildMeta: root.tsx already emits the canonical link
and the hreflang alternates for every route, and a leaf meta that repeats them duplicates
the tags. Use handle.canonicalPath to override the canonical instead.
Render with the existing kit rather than new markup: ~/components/ui (Button, Input, Card,
…) and app/routes/_shared/listing-ui / _page-kit/sections (PageShell, PageHeader,
FilterBar, Pagination, EmptyState, RichText).
5. Verify
pnpm typecheck
Then open /en/stories and /ar/stories, confirm the Arabic page mirrors, and confirm an
unknown slug renders the localised 404 rather than a 500. If the CMS is stopped, the page
must still render its fallback copy.
Fonts (licensed, not in this repository)
The brand faces are Panton for English and Montserrat Arabic for Arabic.
Note on the guideline. The communication guideline PDF names the Arabic face "Montaser Arabic". That is a typo in the guideline — the correct face is Montserrat Arabic, confirmed with the client on 2026-09-11. Both files are supplied.
Both are commercial typefaces licensed to the project, not to this repository —
.gitignore excludes every woff2 under apps/web/public/fonts/, and .dockerignore keeps
them out of built images. Do not commit them, and do not publish them to a CDN that serves
them outside the project's licence.
Drop the files into apps/web/public/fonts/, with exactly these names — matching is
case-sensitive in production:
| File | Family | Weight | Status |
|---|---|---|---|
Panton-Regular.woff2 |
Panton | 400 | supplied |
Panton-Bold.woff2 |
Panton | 700 | supplied |
Panton-SemiBold.woff2 |
Panton | 500 | missing |
Montserrat-Arabic-Regular.woff2 |
Montserrat Arabic | 400 | supplied |
Montserrat-Arabic-Medium.woff2 |
Montserrat Arabic | 500–600 | supplied |
Montserrat-Arabic-Bold.woff2 |
Montserrat Arabic | 700 | supplied |
Because a fresh clone has none of them, the stacks in app/styles/primitives.css degrade
politely and the site is fully usable without any of them:
--font-en: "Panton", "Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-ar: "Montserrat Arabic", "IBM Plex Sans Arabic", "Noto Sans Arabic", ui-sans-serif, system-ui, sans-serif;
The brand family is always first in the stack, so the moment a licensed file is dropped in, it takes over — no code change, no rebuild of anything but the page. One current gap:
- Panton SemiBold.
label-mdasks for weight 500; with no SemiBold face declared, CSS font matching resolves it to Panton Regular. When the cut arrives, add one@font-facefor"Panton"atfont-weight: 500 600inapp/styles/typography.css. Do not declare that face before the file exists — a face that fails to load drops weight-500 text out of the family altogether. Under Docker,docker-compose.ymlmounts./apps/web/public/fontsinto the running web container read-only, so a local container uses your licensed files without ever baking them into an image. On a server, copy them into the deployedbuild/client/fonts/directory (or mount them the same way) as part of the release, outside version control.
Deployment and operations
Building and running a release
pnpm install --frozen-lockfile
pnpm build # cms (admin panel + dist/) then web (build/client + build/server)
pnpm --filter cms start # strapi start :1337
pnpm --filter web start # react-router-serve :$PORT (see below)
Both are ordinary long-running Node processes. Run them under systemd, PM2 or the
containers in docker-compose.yml, behind a reverse proxy (nginx, Caddy) that terminates
TLS and forwards to 3000 and 1337.
Choosing the web server's port
react-router-serve binds PORT when it is set, and otherwise takes the first free port
from 3000 upwards — so a busy 3000 moves the site to 3001, 3002, … with no error, and the
proxy's upstream stops matching. Nothing in the repository defaults it for you.
pnpm --filter web start runs under node --env-file-if-exists=.env, so apps/web/.env is
enough:
echo 'PORT=8080' >> apps/web/.env
pnpm --filter web start # :8080
It has to be that file and not the root .env: Strapi reads a bare PORT too, so a shared
one would move the CMS onto the website's port. apps/web/.env is read by the web server
alone, exactly as apps/cms/.env is read by Strapi alone.
A real environment variable wins over the file, so PORT=9090 pnpm --filter web start and a
systemd unit's Environment=PORT=8080 both override it. HOST works the same way — unset it
listens on every interface, HOST=127.0.0.1 only accepts the reverse proxy on the same host.
Under Docker none of this applies: PORT=3000 is set in the image and in
docker-compose.yml, WEB_PORT in the root .env is the host port the container is
published on (WEB_PORT=8080 gives 8080:3000), and .dockerignore keeps every .env out
of the build context.
Whichever port you pick, SITE_URL stays the public origin (https://sharper.ly), not
http://127.0.0.1:8080 — canonical URLs, hreflang and JSON-LD are built from it.
Production environment
| Variable | Where | Value |
|---|---|---|
NODE_ENV |
both | production |
PORT |
web | the port react-router-serve binds, e.g. 8080. Unset, it takes the first free port from 3000 upwards |
HOST |
web | optional; 127.0.0.1 to accept only the local reverse proxy, unset to listen on every interface |
SITE_URL |
web | https://sharper.ly — canonical URLs, hreflang, JSON-LD are built from it |
STRAPI_URL |
web | the CMS origin, e.g. https://cms.sharper.ly. It is also prefixed onto media URLs, so it must be resolvable by the browser, not only from the server |
STRAPI_TOKEN |
web | only needed for draft/protected content; published content is public |
PUBLIC_URL |
cms | https://cms.sharper.ly — admin redirects and generated links |
IS_PROXIED |
cms | true behind nginx / a load balancer, so X-Forwarded-* is trusted |
CORS_ORIGINS |
cms | https://sharper.ly,https://www.sharper.ly — an empty list blocks every cross-origin browser call, which is the safe default |
DATABASE_* |
cms | managed PostgreSQL; set DATABASE_SSL=true for a hosted database |
APP_KEYS and the other five secrets |
cms | different values from staging, generated with ./scripts/generate-secrets.sh |
Uploaded media is written to apps/cms/public/uploads by the default provider, so give the
CMS a persistent volume — or switch to S3, which apps/cms/config/plugins.ts documents step
by step (remember to add the bucket host to img-src / media-src in
config/middlewares.ts).
Requirements from the terms of reference
The ToR (docs/UEC_SHARPER_Website_TOR V3.docx, sections 3.5 and 3.6) commits the service
provider to an operating standard, not only to a delivery. In summary:
| Requirement | How it is met, and what still has to be set up |
|---|---|
| Secure hosting with SSL | Terminate TLS at the proxy (Let's Encrypt or the host's certificate), redirect HTTP to HTTPS, and enable HSTS. Strapi must run with IS_PROXIED=true behind it. |
| Regular backups | Nightly pg_dump of sharper_cms plus the uploads directory — the database alone does not contain the media files. Keep them off the application server and test a restore before go-live. The commands are in Stopping and tearing down. |
| Security checks and updates | Watch Strapi and Node releases; pnpm --filter cms upgrade:dry previews a Strapi upgrade's codemods. Rotate STRAPI_TOKEN and the admin accounts at handover. |
| Analytics (e.g. Google Analytics) | Not yet integrated. The tag belongs in apps/web/app/root.tsx, in the document <head>, and must respect the site's privacy notice — collect no personal data through it and document what is collected. |
| Monthly viewership report | Comes from the analytics property once it exists: sessions, users, top pages, EN vs AR split (the locale is in the URL prefix, so /en/* and /ar/* aggregate cleanly), referrers, devices. |
| Errors addressed within one day of being reported | Keep a monitored inbox or tracker, and an uptime check on https://sharper.ly/en and https://cms.sharper.ly/_health. Both applications log to stdout; capture it with the process manager. |
| Bi-weekly content support | Editors work in the Strapi admin panel; no deployment is needed for content. Draft & publish gives a review step before anything goes live. |
| Fast loading, mobile responsive, basic accessibility | SSR HTML, no client-side data fetching on first paint, font-display: swap, responsive layouts and logical properties throughout. Re-check with Lighthouse on both /en and /ar before each release. |
| SEO-friendly structure | Server-rendered HTML, one canonical per page, hreflang for en / ar / x-default, JSON-LD (Organization, WebSite, Article, Event, BreadcrumbList), and noindex on filtered listing permutations. A sitemap.xml and robots.txt are not yet built — add them before launch. |
| CMS handover to UEC | The schema lives in apps/cms/src/api/** in version control, the admin panel is self-hosted, and .env.example documents every variable. Hand over: the repository, the server access, the database credentials and a super-admin account. |
Troubleshooting
Unsupported engine / the dev server exits immediately after pnpm dev:web.
React Router 8 requires Node >= 22.22.0 — this was the first thing to bite while
bootstrapping the project. Check with node -v; nvm install "$(cat .nvmrc)" && nvm use
fixes it. pnpm install warns but does not stop you, because .npmrc sets
engine-strict=false so that Strapi's narrower range does not block the workspace.
Strapi exits with Cannot find module 'pg' or knex: Required dependency pg not found.
DATABASE_CLIENT=postgres needs the pg driver. It is already a dependency of apps/cms,
so this means the install is incomplete or was run in the wrong place: run pnpm install
from the repository root, never inside apps/cms.
pnpm install inside apps/web or apps/cms produces an app that will not boot.
Same cause. .npmrc sets node-linker=hoisted for the whole workspace; a nested install
builds a different tree from the one Strapi's plugin resolution expects.
password authentication failed for user "postgres".
A fresh PostgreSQL install has no password for that role. See the collapsed note in
Path A, step 3.
Missing app.keys on CMS start-up. The six secrets are not set. Run
./scripts/generate-secrets.sh --write apps/cms/.env (manual) or
./scripts/generate-secrets.sh --write .env (Docker).
Port already in use. 5432 usually means a PostgreSQL is already running — in Docker mode
the container is published on 5433 for exactly that reason (POSTGRES_PUBLISHED_PORT). For
1337, 3000 and 5173: ss -ltnp | grep -E '1337|3000|5173'.
WARN The field "pnpm.onlyBuiltDependencies" was found in apps/cms/package.json.
Harmless. pnpm only honours that field at the workspace root, where it is already declared.
A new page 404s even though the route module exists. app/routes.ts existence-checks the
section modules while Vite builds the route config, and Vite does not re-evaluate it when a
file appears. Restart pnpm dev:web.
Property 'data' does not exist in a route's meta, or an Arabic page renders English
metadata. In React Router 8 a meta match exposes loaderData, not data;
matches[0].data is undefined at runtime and a type error at compile time. Use
readRootSeo(matches) from ~/routes/_shared/listing.
Type errors about ./+types/.... Those files are generated. Run pnpm typecheck
(which runs react-router typegen first) or pnpm --filter web exec react-router typegen.
The browser reports a CORS error calling the API. Add the origin to CORS_ORIGINS in the
CMS environment and restart it. http://localhost:5173 and http://127.0.0.1:5173 are
always allowed outside production.
Images uploaded in the CMS do not load under Docker. Expected until cms resolves in
your browser — see Images from the CMS.
docker compose complains about depends_on / condition. You are on the old
docker-compose v1 binary. Install the Compose v2 plugin and use docker compose (a space,
not a hyphen).
The site renders in a different typeface than the design. Expected on a fresh clone: the licensed fonts are not in the repository. See Fonts.
Everything looks broken after a schema change in Strapi. strapi develop writes schema
changes to apps/cms/src/api/** and migrates the database; if the two disagree, stop the
CMS, check git status in that folder, and restart. As a last resort in development
only, drop and recreate the database (dropdb sharper_cms && ./scripts/setup-db.sh, or
docker compose down -v) — this destroys all content.
Source documents
docs/ holds the material this site is built from. It is read-only source material —
never edit it, and never invent a figure that is not in it.
| File | What it is |
|---|---|
UEC_SHARPER_Website_TOR V3.docx |
Terms of reference: scope, functional and technical requirements, deliverables, timeline |
SHARPER_Website_Draft_Section_EN_AR.docx |
Approved draft copy for every section, English and Arabic |
SHARPER Home Page .docx |
Home page copy |
SHARP-ER_Website Recommendations.docx |
Structure and content recommendations |
Sharp Updated communication guidline_ (2).pdf |
Brand: colours, typefaces, EU visibility and partner-logo rules, tone of voice |
Tone, everywhere: professional, neutral, factual, inclusive and non-confrontational. The site is not political, not advocacy and not campaign-style.
Licence and credits
Funded by the European Union. Implemented by Acted, United Efforts Consultancy and Stichting Bureau Ensemble. The views expressed on the site are those of the implementing consortium and do not necessarily reflect those of the European Union.
Content, branding and the licensed typefaces belong to the project and its partners; they are not covered by any open-source licence granted over this source code.