---
title: e-support Private KEP, Ioannina
description: >-
  Bilingual website for a private citizen-services bureau in Ioannina: 12 service categories, JSON-LD with GEMI/VAT
  identifiers, an AEO layer with build-time llms.txt and a markdown mirror, and CI gates at every step. From design
  comps to Cloudflare production.
lang: en
source: https://jimrarras.com/en/projects/esupport-ioannina/
fetched: '2026-08-25T11:05:12.000Z'
---

1. [Home](https://jimrarras.com/en/)
2. [Projects](https://jimrarras.com/en/projects/)
3. e-support Private KEP, Ioannina

![e-support Private KEP, Ioannina](https://jimrarras.com/images/projects/esupport-ioannina/hero.webp)

## The system

e-support is a private KEP (citizen service center) in Ioannina: it handles EFKA (social security), DYPA (employment service), AADE (tax authority), KEPA (disability certification), benefits, certificates, vehicle, and pension matters, with in-person service in Ioannina and remote service nationwide. It needed a website that explains clearly what the business does (and what it is not: it is not a public authority), stands up in both Greek and English, and is easy to find for Google and AI assistants alike. Starting from the in-house Astro website-template, I delivered a fully static, bilingual site with the 12 service categories as a content collection, a lead form, and an AEO layer that keeps the machine-readable picture of the site permanently in sync with the site itself.

## Architecture

- **Static Astro 7 + Tailwind v4:** plain static output with no adapter, TypeScript throughout, self-hosted Commissioner via the Astro Fonts API (no third-party font requests)
- **Bilingual i18n:** Greek at the root, English under `/en/`, with Greek slugs in both locales (`/ypiresies/`, not `/en/services/`). A single helper (`localePath()`) builds every locale-prefixed href, and a structural-parity test fails the build if the two dictionaries diverge
- **Content collection with opt-in detail pages:** the 12 service categories are markdown files, and a category gets its own guide page only when its file has a non-empty body. The same body-presence test drives `getStaticPaths`, the listing's links, and the `llms.txt` catalog, so the three can never disagree by construction
- **AEO layer:** `llms.txt` and `robots.txt` are build-time endpoints derived from `site.config.ts`, the i18n dictionaries, and the collection on every build, plus a committed markdown mirror (22 `index.md` twins + `llms-full.txt`) with a `<link rel="alternate" type="text/markdown">` on every route
- **JSON-LD ProfessionalService:** GEMI (business registry) identifier, VAT ID, localized `areaServed` (city + country), split opening hours (morning + evening windows), and a `disambiguatingDescription` stating explicitly that the business is not a public authority
- **Two hosts, one build shape:** production on Cloudflare Workers Static Assets via wrangler, staging as Docker (node:22-alpine → nginx:alpine) on Coolify, with `SITE_URL` and `STAGING_NOINDEX` as build args so staging can never claim production's canonicals

## My contribution

I delivered the site end-to-end as the sole developer: from design to DNS. Beyond the pages themselves, most of the work went into the gates that keep the site maintainable: a hardened placeholder gate that scans 7 files for scaffold strings that survived configuration (with comment-skipping and emptyValue sentinels so real values don't trip false positives), a linkinator crawl of the entire built output, four Playwright suites (smoke, i18n, lead form, responsive), and a WCAG AA contrast audit script that measures the rendered pages. I also digitally recreated the logo, and wrote the per-route OG cards rendered via Playwright for the five top-level routes in both locales.

## Stack details

The most interesting decision is that `llms.txt` is not a static file but a build-time endpoint: it is derived from the same inputs that produce the site (config, dictionaries, collection), so it cannot fall behind, and a broken input fails the build instead of silently serving a wrong catalog. GA4 loads only behind the cookie consent's analytics toggle. The contact form goes through my own self-hosted forms service (rorsa-platform) with the public site key in the markup, no secrets in the repo, and the Cloudflare Turnstile token verified server-side. And the `check:links` gate crawls the entire built dist rather than just the homepage, with a computed skip list instead of a config file that can be silently shadowed.

## Outcomes

- Live at [esupport-ioannina.gr](https://esupport-ioannina.gr), in production on Cloudflare Workers Static Assets
- Bilingual: a full English mirror under `/en/` with structurally guaranteed i18n parity
- Full CI: types, unit tests, placeholder gate, build, dead-link crawl, four e2e suites

## The challenge

The most interesting design challenge was the AEO layer. A hand-written `llms.txt` is correct the day it is written and suspect every day after. The solution was to make the catalog a derivative of the site instead of a description of it: `buildLlmsTxt()` reads `site.config.ts`, the i18n dictionaries, and the services collection through ordinary ES imports (no `readFileSync`, so the builder stays environment-agnostic), and runs on every build. The same holds for which services are advertised as detailed guides: the body-presence test that decides whether a category gets a detail page is the same test that builds the `llms.txt` catalog and the listing's links. When the truth is defined in one place, three surfaces need no synchronisation, and a mistake becomes a build failure instead of silent drift.

## Links

- Live: [esupport-ioannina.gr](https://esupport-ioannina.gr/)

## Related work

- Rorsa Tools: the MCP toolkit behind this site's markdown mirror
- Κόμβος Υγείας & Dr Christos Kolettis: other Astro sites in the same family, without the AEO layer

## Gallery
