Vue d'ensemble

Certaines pages publiques (articles, fiches "pistes d'études") sont indexables par les moteurs de recherche via un rendu SSR maison : NestJS + Handlebars (hbs), sans rapport avec le mode SSR de Quasar/Vue.

Le SSR est "full back" : ces pages ne chargent aucun bundle JS Vue et ne sont jamais hydratées. C'est du HTML généré par Express/NestJS, servi tel quel. Le frontend n'intervient que pour rediriger vers ces URLs (voir plus bas) — il n'y a pas de logique de rendu partagée entre front et back.

Le bloc ssr: {...} dans front/quasar.config.js (mode SSR natif de Quasar) est du boilerplate mort : pas de src-ssr/, build en mode SPA (quasar build, sans -m ssr), dist/spa copié dans le Dockerfile. Ne pas le confondre avec le SSR décrit ici.

Stack et emplacement du code

Quoi
Controllers, services, middlewares api/src/public-pages/
Vues (Handlebars) api/src/public-pages/views/ (layouts/public.hbs, partials/, articles/, pistes/)
Moteur de rendu hbs configuré dans api/src/main.ts (app.setViewEngine('hbs'), layout par défaut layouts/public)
Routes SSR exposées GET /public/articles..., GET /public/etudes..., GET /sitemap*.xml, GET /robots.txt
Routes SPA équivalentes (Vue Router) front/src/router/routes.ts (/etudes/:id/slug/:slug, /articles/:id, etc.) — mêmes contenus, rendus côté client via API JSON, URLs différentes du SSR

Créer une nouvelle page SSR

  1. Ajouter la route dans le controller concerné (public-articles.controller.ts ou public-pistes.controller.ts) avec @Render('dossier/vue').
  2. Écrire la vue .hbs dans views/articles/ ou views/pistes/, elle hérite automatiquement du layout layouts/public.hbs (header/footer/breadcrumb via partials).
  3. Renseigner le SEO via l'objet SeoMeta (titre, description, canonical, robots, OG/Twitter) — consommé par le partial partials/seo-meta.hbs.
  4. Si la page doit porter du JSON-LD (schema.org), utiliser helpers/schema-builder.ts.
  5. Le PublicPageCacheInterceptor (Cache-Control: public, s-maxage=300) s'applique déjà à tout le controller — pas besoin de le regérer par route.

Pas de build/hydratation à prévoir : la page est servie dès que l'API tourne (yarn start:dev / yarn start:prod), les .hbs et assets sont copiés dans dist/ au build Nest (nest-cli.jsonassets: ["public-pages/views/**/*"]).

Activer une page pour le trafic public

L'exposition des routes /public/... côté NestJS ne suffit pas à rendre une page publique : l'activation se fait au niveau nginx, piste par piste (rollout progressif), pas via un flag applicatif.

  1. Ajouter un location dans front/nginx/nginx.conf.template (le seul fichier réellement utilisé, voir section suivante) : nginx location = /etudes/122/slug/but-information-communication { rewrite ^ /public/etudes/but-information-communication last; }
  2. Recharger nginx (rebuild/redeploy du front).
  3. Répercuter l'id de la piste côté front, dans front/src/router/index.ts : ts const SSR_ENABLED_PISTE_IDS = new Set<string>(['122']); Ce set force un window.location.href (rechargement complet, donc repassage par nginx/SSR) quand un lien interne de la SPA pointe vers une piste listée ici. Les deux doivent rester synchronisés à la main — rien ne le vérifie automatiquement.

Front vs back : qui fait quoi

  • Clic sur un lien dans la SPA : Vue Router intercepte, navigation 100% client-side, aucune requête HTML — sauf si la piste ciblée est dans SSR_ENABLED_PISTE_IDS (rechargement forcé, cf. ci-dessus).
  • Refresh / URL tapée directement : repasse par nginx à chaque fois.
  • URL SPA classique sans activation SSR → nginx sert index.html (fallback SPA), pas de HTML pré-rendu.
  • URL avec location SSR active, ou URL /public/etudes|articles/... directe → proxifiée vers NestJS, HTML Handlebars complet.

C'est le piège à retenir : accéder à une page via un lien interne et y accéder en tapant/rafraîchissant l'URL ne suivent pas le même chemin. Un comportement qui marche au clic peut différer au refresh (et inversement), selon l'état d'activation nginx.

Utilisateur déjà connecté (cookie de session présent) sur une page /public/etudes|articles/... → redirigé en 302 vers /app/... (SPA) par RedirectIfAuthenticatedMiddleware. Voir points d'attention ci-dessous sur ce mécanisme.

Environnements : local / staging / prod

Local (dev) Staging / Prod
Serveur front Quasar dev server (Vite), port 8080 nginx (nginx:stable-alpine) servant dist/spa
Routage /public/* → API Proxy Vite, devServer.proxy dans front/quasar.config.jstoujours actif, cible API_URL (défaut http://localhost:3236) location nginx dans nginx.conf.template
Activation par piste Aucune notion équivalente — toutes les pistes sont proxifiées pareil location dédiée par piste (rollout manuel, cf. ci-dessus)
Fichier nginx utilisé front/nginx/nginx.conf.template, templaté via envsubst (${API_HOST}) au démarrage du conteneur (front/Dockerfile)

front/nginx/nginx.conf (sans .template) n'est utilisé nulle part — ni Dockerfile, ni docker-compose, ni CI. C'est un fichier orphelin, qui a divergé de nginx.conf.template : il montre un état où le SSR est actif, alors que le fichier réellement déployé le désactive (voir section suivante). Pour changer le comportement nginx en staging/prod, éditer nginx.conf.template, jamais nginx.conf.

API_HOST (défaut back.inspire-orientation.org) n'est pas surchargé par la CI (.gitlab-ci.yml) : staging et prod utilisent la même valeur par défaut, pas de différenciation visible à ce niveau dans ce repo.

État actuel & points d'attention (2026-08)

  • Le SSR public est désactivé en prod aujourd'hui : nginx.conf.template a un location ~ ^/public/(etudes|articles)(/|$) { return 404; } et aucune location de rollout piste active. Le code backend/Handlebars est fonctionnel et testé (api/test/public-pages.e2e-spec.ts), mais aucune page SSR n'est accessible depuis le domaine public tant que ce fichier n'est pas modifié.
  • Mismatch probable de nom de cookie de session : RedirectIfAuthenticatedMiddleware lit process.env.SESSION_COOKIE_NAME ?? 'inspire_token', mais le front stocke le JWT sous le cookie `${process.env.APP_NAME}` (= inspire-v2-api). SESSION_COOKIE_NAME n'est défini nulle part dans ce repo. Tant que ce n'est pas aligné, la redirection des utilisateurs connectés vers la SPA ne se déclenche probablement jamais.
  • Conflit de route /sitemap.xml : deux controllers différents (general/sitemap/ et public-pages/) déclarent tous les deux GET sitemap.xml. L'ordre d'import dans app.module.ts (GeneralModule avant PublicPagesModule) fait que celui de public-pages est probablement inatteignable sur ce chemin précis.
  • robots.txt à 3 couches : servi par nginx (map $host $robots_content, prioritaire en prod), par le controller public-pages, et par un fichier statique (api/src/public/robots.txt). Seule la couche nginx est visible depuis le domaine public.
  • legacy-redirect.middleware.ts n'est jamais appliqué (code écrit, jamais enregistré dans un module) — les anciennes URLs qu'il est censé rediriger en 301 ne le sont pas.
  • Maillage interne cassé : les liens "pistes similaires" dans pistes/detail.hbs pointent vers le format SPA (/etudes/:id/slug/:slug), pas vers l'URL SSR canonique (/public/etudes/:slug). Si la piste liée n'a pas de location nginx dédiée, le lien retombe sur la SPA — casse la continuité SSR pour le crawl SEO.

Articulation avec la SPA Vue

Fait : - Routes miroir en Vue Router pour les mêmes contenus (rendu client via API JSON), coexistant avec les routes SSR. - Redirection des utilisateurs connectés depuis les pages SSR vers la SPA (RedirectIfAuthenticatedMiddleware), sous réserve du mismatch cookie ci-dessus. - Bascule forcée en rechargement complet pour les pistes listées dans SSR_ENABLED_PISTE_IDS, pour que la navigation interne repasse bien par le SSR quand il est actif.

À faire / flou : - Synchronisation nginx.conf.templateSSR_ENABLED_PISTE_IDS entièrement manuelle, rien ne la vérifie ni ne l'automatise. - Le lien "Connexion | Inscription" du header SSR (partials/header.hbs) pointe vers /, pas vers un vrai flux SSO — probablement un placeholder non finalisé.

Bloquant avant réactivation du SSR en prod : - Vérifier/aligner SESSION_COOKIE_NAME avec le cookie réellement posé par le front. - Trancher le conflit /sitemap.xml entre les deux modules. - Décider si nginx.conf (fichier orphelin) doit être supprimé ou resynchronisé avec nginx.conf.template, pour éviter que quelqu'un l'édite par erreur en pensant qu'il est utilisé.