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
- Ajouter la route dans le controller concerné (
public-articles.controller.tsoupublic-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. - Écrire la vue
.hbsdansviews/articles/ouviews/pistes/, elle hérite automatiquement du layoutlayouts/public.hbs(header/footer/breadcrumb via partials). - Renseigner le SEO via l'objet
SeoMeta(titre, description, canonical, robots, OG/Twitter) — consommé par le partialpartials/seo-meta.hbs. - Si la page doit porter du JSON-LD (schema.org), utiliser
helpers/schema-builder.ts. - 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>, vialayouts/public.hbs) : snippet de chargement GTM/sGTM standard (containerGTM-NHNLXJLF, hébergé sursst.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'objetGtmCtaConfig), et — seulement sicfg.pageTitleest renseigné — pousse aussi unpageview. Configuré par l'objetGtmPageConfig(types.ts) :pageTitle,pagePath,role— sérialisé côté vue via le helper Handlebarsjson(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 :
- Le service de la page renseigne un champ
gtm: GtmPageConfigdans son retour (ex.home-page.service.ts) :ts gtm: { pageTitle: 'HP Globale', pagePath: '/', role: RoleTrackingEnum.Guest, }, - Pour tracker un clic sur un CTA, passer
gtmCta='objetCtaGtm'à l'appel{{> cta-button}}— le partial le reporte endata-gtm-ctasur le<a>, quegtm-page.hbsécoute :hbs {{> cta-button href='...' label="Je m'inscris" colorBackgroundClass="background-yellow" gtmCta=heroCtaGtm }}avects heroCtaGtm: { event: 'cta_home', eventCategory: 'Homepage', eventAction: 'Globale', eventName: "Je m'inscris", role: RoleTrackingEnum.Guest, },Sansdata-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.
- Ajouter un
locationdansfront/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; } - Recharger nginx (rebuild/redeploy du front).
- 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 unwindow.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
locationSSR 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-orientationet/echange-etudiantsont servies en SSR en prod et en staging (nginx réécrit en interne vers/public/home|test-orientation|echange-etudiantcô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 dansnginx-staging.conf.templateetnginx.conf.template. Les navigations internes (router.push('/lyceen'), ex.DemoLogin.vue) ne passant pas par nginx, lebeforeEnterreloadThroughNginx(front/src/router/public.ts), posé sur les routeslyceenetpublic_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-orientationen rechargement complet. - Conflit de route
/sitemap.xml: deux controllers différents (general/sitemap/etpublic-pages/) déclarent tous les deuxGET sitemap.xml. L'ordre d'import dansapp.module.ts(GeneralModuleavantPublicPagesModule) fait que celui depublic-pagesest probablement inatteignable sur ce chemin précis. robots.txtà 3 couches : servi par nginx (map $host $robots_content, prioritaire en prod), par le controllerpublic-pages, et par un fichier statique (api/src/public/robots.txt). Seule la couche nginx est visible depuis le domaine public.legacy-redirect.middleware.tsn'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.hbspointent 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 delocationnginx 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_IDSentièrement manuelle, rien ne la vérifie ni ne l'automatise.
Bloquant avant réactivation du SSR en prod :
- Trancher le conflit
/sitemap.xmlentre les deux modules. - Décider si
nginx.conf(fichier orphelin) doit être supprimé ou resynchronisé avecnginx.conf.template, pour éviter que quelqu'un l'édite par erreur en pensant qu'il est utilisé.