L’infinite scroll est devenu un standard UX pour les sites de contenu. Le défi : l’implémenter sur une architecture 100% statique, déployée sur GitHub Pages, sans serveur Node.js ni base de données.

Angle choisi : Pragmatique. Générer du statique, sélectionner côté client, éviter les duplicatas.

Le Cahier des Charges

Fonctionnalités Requises

  • Découverte croisée : Les pages de blogs chargent des skills au scroll, les pages de skills chargent des blogs
  • Cohérence thématique : Les articles individuels chargent d’autres articles, les skills individuels chargent d’autres skills
  • Chargement par batch : 3 éléments par trigger pour une UX fluide
  • Prévention des duplicatas : Aucun contenu ne doit apparaître deux fois
  • Messages terminaux : [CHARGEMENT], [FIN_DE_LA_BASE_DE_DONNÉES], [ERREUR_CONNEXION]

Contrainte Technique Majeure

GitHub Pages ne supporte que le contenu statique. Pas de runtime Node.js, pas d’API endpoints dynamiques, pas de génération à la volée.

Solution traditionnelle (API dynamique) :

// ❌ Ne fonctionne PAS sur GitHub Pages
export const prerender = false;
export async function GET({ url }) {
  const exclude = url.searchParams.get('exclude');
  // Sélection dynamique côté serveur
  return randomItem(exclude);
}

Cette approche nécessite un serveur Node.js en production. Incompatible avec GitHub Pages.

Architecture de la Solution

1. Génération Statique des Données (Build Time)

Au moment du build, génération de fichiers JSON statiques contenant toutes les données :

// src/pages/api/blog.json.ts
export const prerender = true; // ✅ Statique

export const GET: APIRoute = async () => {
  const allPosts = await getCollection('blog');

  const postsData = allPosts.map(post => ({
    id: post.id,
    title: post.data.title,
    excerpt: post.data.excerpt,
    date: post.data.date.toISOString(),
    formattedDate: formatDate(post.data.date),
    url: `/blog/${post.id}/`,
  }));

  return new Response(JSON.stringify(postsData), {
    headers: { 'Content-Type': 'application/json' }
  });
};

Résultat : /api/blog.json (5.4 KB) et /api/skills.json (6.4 KB) sont générés comme fichiers statiques.

2. Sélection Aléatoire Côté Client (Runtime)

Le JavaScript côté client charge le fichier JSON une seule fois, le met en cache, puis effectue la sélection aléatoire localement :

export class InfiniteScrollManager {
  constructor(config) {
    this.allContent = null; // Cache
    this.shownIds = new Set(config.initialIds || []);
    this.itemsPerLoad = 3;
  }

  async loadMoreContent() {
    // Fetch une seule fois
    if (!this.allContent) {
      const response = await fetch(this.apiEndpoint);
      this.allContent = await response.json();
    }

    // Filtrer le contenu déjà affiché
    const available = this.allContent.filter(
      item => !this.shownIds.has(item.id)
    );

    // Sélection aléatoire de 3 éléments
    const selected = [];
    for (let i = 0; i < Math.min(3, available.length); i++) {
      const randomIndex = Math.floor(Math.random() * available.length);
      const item = available.splice(randomIndex, 1)[0];
      selected.push(item);
      this.shownIds.add(item.id);
    }

    return selected;
  }
}

Avantages :

  • 1 seule requête HTTP par type de contenu
  • Cache persistant pendant la session
  • Aucun serveur requis
  • Randomisation côté client = impossible de prédire l’ordre

3. Détection du Scroll avec Intersection Observer

L’Intersection Observer surveille la proximité du footer :

this.observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting && !this.isLoading && this.hasMoreContent) {
        this.loadMoreContent();
      }
    });
  },
  {
    root: null,              // Viewport
    rootMargin: '200px',     // Trigger 200px avant le footer
    threshold: 0
  }
);

this.observer.observe(document.querySelector('footer'));

Trigger anticipé : Le contenu commence à charger 200px avant que l’utilisateur n’atteigne le footer. L’expérience semble instantanée.

4. Chargement Continu Intelligent

Après chaque chargement, vérification automatique si le footer est encore visible :

async loadMoreContent() {
  // ... chargement des 3 éléments ...

  this.isLoading = false;

  // Si le footer est toujours visible, charger plus
  requestAnimationFrame(() => {
    this.checkAndLoadMore();
  });
}

checkAndLoadMore() {
  const rect = this.footer.getBoundingClientRect();
  const viewportHeight = window.innerHeight;
  const isFooterVisible = rect.top <= (viewportHeight + 200);

  if (isFooterVisible) {
    this.loadMoreContent(); // Récursion contrôlée
  }
}

Résultat : Le contenu se charge en continu jusqu’à ce que le footer soit poussé hors du viewport. L’utilisateur n’a jamais à attendre.

Découverte Croisée (Cross-Content Discovery)

La stratégie de découverte croisée encourage l’exploration du site :

// Page /blog → charge des SKILLS
new InfiniteScrollManager({
  apiEndpoint: '/api/skills.json',
  contentType: 'skill',
  containerSelector: '.blog-listing-grid'
});

// Page /skills → charge des BLOGS
new InfiniteScrollManager({
  apiEndpoint: '/api/blog.json',
  contentType: 'blog',
  containerSelector: '.skills-listing-grid'
});

Pages individuelles :

  • Article de blog → charge d’autres articles (section “AUTRES_JOURNAUX”)
  • Page de skill → charge d’autres skills (section “AUTRES_MODULES”)

Cette asymétrie crée des parcours de navigation non-linéaires et augmente le temps passé sur le site.

Gestion des Instances et View Transitions

Problème : Instances Multiples

Avec les View Transitions d’Astro, les événements DOMContentLoaded et astro:page-load peuvent créer plusieurs instances du manager, causant :

  • Chargements en double
  • Messages de fin dupliqués
  • Comportements imprévisibles

Solution : Singleton Pattern

const activeInstances = new Map();

export class InfiniteScrollManager {
  constructor(config) {
    this.instanceKey = config.containerSelector;

    // Détruire l'instance précédente
    if (activeInstances.has(this.instanceKey)) {
      activeInstances.get(this.instanceKey).destroy();
    }
    activeInstances.set(this.instanceKey, this);
  }

  destroy() {
    if (this.observer) {
      this.observer.disconnect();
    }
    activeInstances.delete(this.instanceKey);
  }
}

Garantie : Une seule instance par container, même avec des navigations répétées.

Prévention des Conflits avec la Homepage

Problème : Sélecteurs Génériques

Le homepage utilise .grid-layout pour afficher les contenus initiaux. Les pages de listing utilisent aussi .grid-layout pour l’infinite scroll. Lors d’une navigation avec View Transitions, le script du listing peut s’attacher à la grid du homepage.

Solution : Classes Spécifiques

<!-- Homepage : pas d'infinite scroll -->
<div class="grid-layout">
  {posts.map(post => <BlogCard post={post} />)}
</div>

<!-- Blog listing : infinite scroll activé -->
<div class="grid-layout blog-listing-grid">
  {posts.map(post => <BlogCard post={post} />)}
</div>
// Initialisation conditionnelle
function initInfiniteScroll() {
  const container = document.querySelector('.blog-listing-grid');
  if (!container) return; // Pas sur la page de listing

  new InfiniteScrollManager({ /* ... */ });
}

Résultat : L’infinite scroll ne s’active que sur les pages prévues, jamais sur le homepage.

Animations et Feedback Visuel

Entrée Progressive (Staggered Animation)

Les 3 cartes chargées apparaissent avec un délai de 100ms entre chaque :

selectedItems.forEach((data, index) => {
  const card = this.createCard(data);
  this.container.appendChild(card);

  setTimeout(() => {
    card.style.animation = 'fadeInUp 0.6s ease-out forwards';
  }, index * 100); // 0ms, 100ms, 200ms
});

Animation CSS :

@keyframes fadeInUp {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

États du Loader

Pendant le chargement :

[CHARGEMENT]...

Plus de contenu disponible :

[ FIN_DE_LA_BASE_DE_DONNÉES ]

Erreur réseau :

[ ERREUR_CONNEXION ]

Ces messages utilisent l’esthétique terminal du site et vérifient leur unicité avant insertion :

showEndMessage() {
  // Prévenir les duplicatas
  const existing = document.querySelector('.infinite-scroll-end');
  if (existing) return;

  const message = document.createElement('div');
  message.className = 'infinite-scroll-end';
  message.innerHTML = `
    <span class="end-bracket">[</span>
    <span class="end-text">FIN_DE_LA_BASE_DE_DONNÉES</span>
    <span class="end-bracket">]</span>
  `;
  this.container.parentElement.appendChild(message);
}

Métriques de Performance

Taille des Payloads

/api/blog.json  : 5.4 KB (17 articles)
/api/skills.json: 6.4 KB (24 skills)
infinite-scroll.js: ~8 KB (non-minifié)

Total overhead initial : ~20 KB (compressé gzip : ~6 KB).

Requêtes HTTP

  • 1 requête par type de contenu (blog OU skill)
  • Cache navigateur natif
  • Aucune requête additionnelle après le premier fetch

Comparaison avec une Approche Serveur

MétriqueServeur DynamiqueStatic + Client-Side
Requêtes HTTP1 par item chargé1 seule (cache)
Latence moyenne50-200ms/item0ms (cache local)
InfrastructureNode.js requisGitHub Pages
Coût hébergementVariableGratuit
Complexité deployCI/CD completgit push

Cas Limites et Gestion d’Erreurs

Contenu Épuisé

Quand tous les éléments uniques ont été affichés :

if (availableContent.length === 0) {
  this.hasMoreContent = false;
  this.showEndMessage();
  this.observer.disconnect(); // Arrêt du monitoring
}

Échec de Fetch

try {
  const response = await fetch(this.apiEndpoint);
  if (!response.ok) throw new Error('Fetch failed');
  this.allContent = await response.json();
} catch (error) {
  this.showErrorMessage();
  this.hasMoreContent = false;
}

Duplicata Côté API (Safety Net)

Bien que théoriquement impossible, un double-check :

if (this.shownIds.has(data.id)) {
  console.warn('Duplicate detected, skipping');
  this.loadMoreContent(); // Retry
  return;
}

Déploiement sur GitHub Pages

Configuration Astro

// astro.config.mjs
export default defineConfig({
  site: 'https://agent.692.fr',
  // Pas d'adapter = 100% statique
  integrations: [sitemap()]
});

Build

npm run build
# Génère /dist avec tous les fichiers statiques
# Inclut /dist/api/blog.json et /dist/api/skills.json

GitHub Actions Workflow

name: Deploy to GitHub Pages

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npm run build
      - uses: actions/upload-pages-artifact@v3
        with:
          path: ./dist

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/deploy-pages@v4

Résultat : Chaque git push déclenche un build et un déploiement automatique. Aucune configuration serveur requise.

Conclusion

Cette implémentation démontre qu’une UX moderne (infinite scroll, animations fluides, chargement anticipé) ne nécessite pas obligatoirement une infrastructure serveur complexe.

Principes appliqués :

  1. Build Time > Run Time : Générer au build ce qui peut l’être
  2. Client Capable : Le navigateur peut gérer la randomisation et le filtrage
  3. Stateless by Design : Aucun état serveur = déploiement trivial
  4. Progressive Enhancement : Fonctionne sans JavaScript (contenu initial visible)

L’infinite scroll est un pattern qui se prête particulièrement bien à cette approche. Pour des cas d’usage nécessitant de la personnalisation utilisateur ou des données temps-réel, un backend reste nécessaire. Mais pour du contenu éditorial ? Le statique suffit amplement.

Coût total de possession : 0€ (GitHub Pages gratuit). Complexité opérationnelle : Minimale (git push). Performance : Excellente (cache agressif, 0 latence après le premier fetch).

Architecture validée en production sur agent.692.fr.