Pages SSR (rendu côté serveur)

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 Où
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 /public/test-orientation, GET /public/echange-etudiant, GET /public/home, 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'). Pour une page isolée sans famille de contenu existante (ex. test-orientation.controller.ts), un controller dédié à une seule route suit le même schéma.
  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: private, no-cache : la réponse dépend du cookie de connexion, le navigateur doit revalider à chaque visite) 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.json → assets: ["public-pages/views/**/*"]).

Icônes/logos SVG utilisés par les pages SSR (header, futurs partials…) : les déposer dans api/src/public/svg/ssr/, jamais directement dans api/src/public/svg/. Ce dossier partage son URL (/svg/...) avec les icônes propres à la SPA front (front/public/svg/) ; sans le sous-dossier dédié, un nom de fichier identique des deux côtés (ex. school.svg) rend impossible un proxy ciblé sans lister chaque fichier. /svg/ssr/ est proxifié en bloc (front/quasar.config.js en local, location ^~ /svg/ssr/ dans nginx.conf.template en staging/prod) : tout nouveau fichier déposé là est servi automatiquement, sans toucher à la config.

Carrousel partenaires

Partial : views/partials/partners-carousel.hbs. Liste par défaut : services/partners.data.ts, exposée par le helper Handlebars partnersList (helpers/handlebars-helpers.ts). Ne pas passer partners dans le service si la page utilise la liste commune.

{{> partners-carousel}}

Pour une liste propre à la page, fournir un tableau { name, url, imageUrl }[] depuis le service, puis :

{{> partners-carousel
  partners=partners
}}

Le helper prend cet override s'il est non vide, sinon la liste par défaut. Pas besoin de recopier le HTML du carrousel.

La SPA a un autre slider hardcodé (front/src/components/home/PartnersSlider.vue) : les deux listes ne sont pas synchronisées.

Connexion (SSO)

Le lien "Connexion | Inscription" (partials/header.hbs) pointe vers ssoUrl() (helpers/url-builder.ts), qui renvoie ${API_URL}/sso/redirect.

GET /sso/redirect (SsoController) appelle userJwtService.createJwt() puis fait un @Redirect() vers ${SSO_HOST}/orientation?auth_req_jwt=<jwt>.

Ce n'est pas un doublon de GET auth-jwt/sso-jwt (AuthJwtController.getJwt), qui appelle le même createJwt() : ce dernier renvoie le JWT en JSON pour un fetch côté SPA, alors que le lien <a href> d'une page SSR doit pouvoir être suivi sans JS — d'où un contrôleur dédié avec @Redirect(). Seule la génération du JWT (userJwtService.createJwt()) est partagée ; la livraison diffère volontairement.

Tracking GTM

Deux partials, tous les deux gardés par {{#if gtm}} :

  • partials/gtm-head.hbs (dans le <head>, via layouts/public.hbs) : snippet de chargement GTM/sGTM standard (container GTM-NHNLXJLF, hébergé sur sst.inspire-orientation.org).
  • partials/gtm-page.hbs (fin de <body>) : écoute les clics sur tout élément [data-gtm-cta] pour pousser un événement CTA (configuré par l'objet GtmCtaConfig), et — seulement si cfg.pageTitle est renseigné — pousse aussi un pageview. Configuré par l'objet GtmPageConfig (types.ts) : pageTitle, pagePath, role — sérialisé côté vue via le helper Handlebars json (helpers/handlebars-helpers.ts).

gtm a une valeur par défaut ({ cookieName: process.env.APP_NAME }) posée une fois pour toutes via app.setLocal('gtm', ...) dans main.ts : Express fusionne les app.locals dans le contexte de toutes les vues, donc {{#if gtm}} est vrai sur chaque page sans rien à faire par page. Ce défaut suffit à activer le header (partials/header.hbs, dont le CTA porte déjà tous ses data-gtm-*) partout, mais pas le pageview (bloqué par le if (cfg.pageTitle) tant qu'aucune vraie config de page n'a été fournie).

Pour activer le tracking complet (pageview + CTA de la page) sur une page :

  1. Le service de la page renseigne un champ gtm: GtmPageConfig dans son retour (ex. home-page.service.ts) : ts gtm: { pageTitle: 'HP Globale', pagePath: '/', role: RoleTrackingEnum.Guest, },
  2. Pour tracker un clic sur un CTA, passer gtmCta='objetCtaGtm' à l'appel {{> cta-button}} — le partial le reporte en data-gtm-cta sur le <a>, que gtm-page.hbs écoute : hbs {{> cta-button href='...' label="Je m'inscris" colorBackgroundClass="background-yellow" gtmCta=heroCtaGtm }} avec ts heroCtaGtm: { event: 'cta_home', eventCategory: 'Homepage', eventAction: 'Globale', eventName: "Je m'inscris", role: RoleTrackingEnum.Guest, }, Sans data-gtm-cta, l'élément est simplement ignoré par l'écouteur de clic.

Sans l'étape 1, gtm reste le défaut posé dans main.ts ({ cookieName: ... }, sans pageTitle) : les partials s'exécutent quand même (header tracké), mais le pageview et les CTA propres à la page restent inactifs tant que l'étape 1 n'est pas faite.

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é sur une page d'atterrissage (/public/home|test-orientation|echange-etudiant) → redirigé en 302 vers ${FRONTEND_URL}/accueil par RedirectIfAuthenticatedMiddleware (repli sur /accueil relatif sans FRONTEND_URL). « Connecté » = cookie JWT du front (${APP_NAME}) non vide : le logout du front laisse le cookie avec une valeur vide (Cookies.remove(name, cookieOptions) écrase expires: -1 par expires: 30). L'API doit donc avoir APP_NAME (même valeur que le front) et FRONTEND_URL dans son env. etudes/articles ne sont volontairement pas redirigées : les chemins SSR (/public/etudes/:slug, /public/articles/:cat/:slug…) ne correspondent pas aux routes SPA (/etudes/:id/slug/:slug, /articles/:id…), une réécriture /public → /app tomberait sur des 404.

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.js — toujours 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)

Pour changer le comportement nginx, éditer nginx.conf.template (prod) et nginx-staging.conf.template (staging), jamais nginx.conf. Nouvelle page SSR : ajouter le slug dans la location des URLs propres (réécriture interne vers /public/<slug>) et dans la 301 /public/<slug> → /<slug>, dans les deux fichiers.

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)

  • Deux configs nginx : /, /test-orientation et /echange-etudiant sont servies en SSR en prod et en staging (nginx réécrit en interne vers /public/home|test-orientation|echange-etudiant côté API ; les anciennes URLs /public/... sont redirigées en 301, query string conservée). /public/(etudes|articles) renvoie 404 en prod (nginx.conf.template) et est proxifié vers l'API en staging (nginx-staging.conf.template), seul endroit où ces pages se consultent.
  • Redirection /lyceen → /test-orientation (INSP-727) : location ~ ^/lyceen/?$ en 301 dans nginx-staging.conf.template et nginx.conf.template. Les navigations internes (router.push('/lyceen'), ex. DemoLogin.vue) ne passant pas par nginx, le beforeEnter reloadThroughNginx (front/src/router/public.ts), posé sur les routes lyceen et public_home, force un rechargement complet pour que nginx serve la page SSR. En local (pas de nginx), le premier chargement passe et affiche les pages SPA (LandingLyceen.vue, Home.vue). Guard et pages SPA à supprimer (ticket de nettoyage) une fois les liens internes pointés vers /test-orientation en rechargement complet.
  • 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 d'atterrissage SSR vers /accueil (RedirectIfAuthenticatedMiddleware).
  • 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.template ↔ SSR_ENABLED_PISTE_IDS entièrement manuelle, rien ne la vérifie ni ne l'automatise.

Bloquant avant réactivation du SSR en prod :

  • 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é.