8000+ Pagine Location SEO in AEM senza Nodi JCR: 3 Soluzioni a Confronto

8000+ Pagine Location SEO in AEM senza Nodi JCR: 3 Soluzioni a Confronto

Il Contesto

Un cliente ha richiesto di creare una pagina dedicata per ogni regione, provincia e comune d'Italia per mappare risorse locali e migliorare il posizionamento SEO.

I Numeri

  • 20 regioni
  • ~110 province
  • ~8000 comuni
  • Totale: ~8130 pagine

I Requisiti

HTML reale server-side (non SPA/React) per essere indicizzate dai motori di ricerca ✅ URL puliti e SEO-friendly: /locations/lombardia/milano/navigli.htmlContenuti dinamici basati sulla location (nome regione/provincia/comune) ❌ Zero nodi nel JCR (o il minimo indispensabile) per non appesantire l'authoring

Il Problema

Creare 8130 pagine fisiche in /content sarebbe un disastro:

  • ❌ Performance degradate del siteadmin
  • ❌ Backup/restore lentissimi
  • ❌ Deploy complessi
  • ❌ Impossibilità di manutenzione scalabile
  • ❌ Rischio di timeout durante il rendering dell'albero

Domanda: Come generare migliaia di pagine HTML SEO-friendly senza creare migliaia di nodi JCR?


Soluzione 1: JSON ISTAT + In-Memory Service (Implementata)

Questa è la soluzione che ho implementato nel progetto reale.

Strategia

  1. Dati ISTAT: Scaricare CSV ufficiale ISTAT con regioni/province/comuni
  2. Conversione JSON: Script per convertire CSV → JSON strutturato
  3. Service in-memory: Caricare JSON in RAM all'avvio di AEM
  4. Dispatcher rewrite: Convertire URL puliti in selettori Sling
  5. Filter + Session: Validare location e passare dati via sessione
  6. Sling Rewriter: Sostituire placeholder HTML con dati reali

1. Dati ISTAT - Fonte Ufficiale

Fonte dati: ISTAT - Codici dei comuni, delle province e delle regioni

L'ISTAT (Istituto Nazionale di Statistica) fornisce il dataset ufficiale con tutti i comuni italiani in formato CSV. Il file contiene:

  • Nome ufficiale (con spazi, apostrofi, caratteri speciali)
  • Codice ISTAT
  • Provincia di appartenenza
  • Regione di appartenenza

Problema: I nomi contengono caratteri non URL-safe (Valle d'Aosta, Reggio nell'Emilia, Saint-Vincent).

Soluzione: Script di conversione CSV → JSON con generazione automatica di slug URL-safe.

Struttura JSON Target

Il CSV ISTAT contiene nomi ufficiali con spazi e caratteri speciali. Ho creato uno script per generare slug URL-safe:

{
  "regioni": [
    {
      "name": "Valle d'Aosta",           // Nome ufficiale
      "slug": "valle-d-aosta",            // URL-safe
      "province": [
        {
          "name": "Aosta",
          "slug": "aosta",
          "comuni": [
            {
              "name": "Saint-Vincent",
              "slug": "saint-vincent"
            }
          ]
        }
      ]
    },
    {
      "name": "Emilia-Romagna",
      "slug": "emilia-romagna",
      "province": [
        {
          "name": "Reggio nell'Emilia",
          "slug": "reggio-emilia",
          "comuni": [
            {
              "name": "Reggio nell'Emilia",
              "slug": "reggio-emilia"
            }
          ]
        }
      ]
    }
  ]
}

File salvato in: src/main/resources/data/comuni-italia.json

Script di Conversione CSV → JSON

// convert-istat-csv.js
// Download CSV da: https://www.istat.it/classificazione/...

const fs = require('fs');
const csv = require('csv-parser');

const results = {
  regioni: []
};

const regioniMap = new Map();
const provinceMap = new Map();

// Funzione per creare slug URL-safe
function slugify(text) {
  return text
    .toLowerCase()
    .normalize('NFD')                   // Decompone caratteri accentati
    .replace(/[\u0300-\u036f]/g, '')    // Rimuove accenti
    .replace(/['']/g, '')               // Rimuove apostrofi
    .replace(/[^a-z0-9]+/g, '-')        // Sostituisce non-alfanumerici con -
    .replace(/^-+|-+$/g, '');           // Rimuove - iniziali/finali
}

fs.createReadStream('comuni-istat.csv')
  .pipe(csv({ separator: ';' }))
  .on('data', (row) => {

    const regioneName = row['Denominazione regione'];
    const provinciaName = row['Denominazione provincia'];
    const comuneName = row['Denominazione comune'];

    // Salta intestazioni
    if (!regioneName || regioneName === 'Denominazione regione') return;

    const regioneSlug = slugify(regioneName);
    const provinciaSlug = slugify(provinciaName);
    const comuneSlug = slugify(comuneName);

    // Crea regione se non esiste
    if (!regioniMap.has(regioneSlug)) {
      const regione = {
        name: regioneName,
        slug: regioneSlug,
        province: []
      };
      regioniMap.set(regioneSlug, regione);
      results.regioni.push(regione);
    }

    const regione = regioniMap.get(regioneSlug);

    // Crea provincia se non esiste
    const provinciaKey = `${regioneSlug}/${provinciaSlug}`;
    if (!provinceMap.has(provinciaKey)) {
      const provincia = {
        name: provinciaName,
        slug: provinciaSlug,
        comuni: []
      };
      provinceMap.set(provinciaKey, provincia);
      regione.province.push(provincia);
    }

    const provincia = provinceMap.get(provinciaKey);

    // Aggiungi comune
    provincia.comuni.push({
      name: comuneName,
      slug: comuneSlug
    });
  })
  .on('end', () => {
    // Salva JSON
    fs.writeFileSync(
      'comuni-italia.json',
      JSON.stringify(results, null, 2),
      'utf-8'
    );

    console.log('✅ Conversione completata!');
    console.log(`   Regioni: ${results.regioni.length}`);
    console.log(`   Province: ${provinceMap.size}`);

    let totaleComuni = 0;
    results.regioni.forEach(r =>
      r.province.forEach(p =>
        totaleComuni += p.comuni.length
      )
    );
    console.log(`   Comuni: ${totaleComuni}`);
  });

Esecuzione:

npm install csv-parser
node convert-istat-csv.js
# Output: comuni-italia.json (~1.5 MB)

Esempi di slug generati:

  • Valle d'Aostavalle-d-aosta
  • Reggio nell'Emiliareggio-emilia
  • Bolzano/Bozenbolzano-bozen
  • Forlì-Cesenaforli-cesena

2. Service In-Memory con Nested JSON

@Component(service = LocationDataService.class, immediate = true)
public class LocationDataServiceImpl implements LocationDataService {

    private static final Logger LOG = LoggerFactory.getLogger(LocationDataServiceImpl.class);

    private JSONObject locationsData;

    @Activate
    protected void activate() throws Exception {
        LOG.info("Loading ISTAT locations data...");

        // Carica JSON da classpath
        InputStream jsonStream = getClass().getResourceAsStream("/data/comuni-italia.json");
        String jsonString = IOUtils.toString(jsonStream, StandardCharsets.UTF_8);

        // Parse JSON nativo (org.json o Gson)
        this.locationsData = new JSONObject(jsonString);

        // Conta totale location per logging
        int count = countLocations(locationsData);
        LOG.info("✅ Loaded {} locations in memory (~{}MB)", count,
                 Runtime.getRuntime().totalMemory() / 1024 / 1024);
    }

    @Override
    public LocationData get(String regionSlug, String provinciaSlug, String comuneSlug) {

        try {
            // Lookup gerarchico: regioni → provincia → comune
            JSONArray regioni = locationsData.getJSONArray("regioni");

            // Trova regione
            JSONObject regione = findBySlug(regioni, regionSlug);
            if (regione == null) return null;

            // Trova provincia
            JSONArray province = regione.getJSONArray("province");
            JSONObject provincia = findBySlug(province, provinciaSlug);
            if (provincia == null) return null;

            // Trova comune
            JSONArray comuni = provincia.getJSONArray("comuni");
            JSONObject comune = findBySlug(comuni, comuneSlug);
            if (comune == null) return null;

            // Costruisci LocationData
            return new LocationData(
                comune.getString("name"),
                provincia.getString("name"),
                regione.getString("name")
            );

        } catch (JSONException e) {
            LOG.warn("Location not found: {}/{}/{}", regionSlug, provinciaSlug, comuneSlug);
            return null;
        }
    }

    @Override
    public boolean exists(String regionSlug, String provinciaSlug, String comuneSlug) {
        return get(regionSlug, provinciaSlug, comuneSlug) != null;
    }

    // Helper: trova oggetto in array per slug
    private JSONObject findBySlug(JSONArray array, String slug) throws JSONException {
        for (int i = 0; i < array.length(); i++) {
            JSONObject obj = array.getJSONObject(i);
            if (slug.equals(obj.getString("slug"))) {
                return obj;
            }
        }
        return null;
    }

    private int countLocations(JSONObject data) throws JSONException {
        int count = 0;
        JSONArray regioni = data.getJSONArray("regioni");
        for (int i = 0; i < regioni.length(); i++) {
            JSONArray province = regioni.getJSONObject(i).getJSONArray("province");
            for (int j = 0; j < province.length(); j++) {
                count += province.getJSONObject(j).getJSONArray("comuni").length();
            }
        }
        return count;
    }
}

Vantaggi approccio nested:

  • Semplicità: Rispecchia la gerarchia naturale dei dati
  • Nessuna chiave composita: Navigazione diretta regione → provincia → comune
  • Memory footprint: ~2-5 MB (identico alla HashMap)
  • Performance: O(1) per regione + O(1) per provincia + O(1) per comune = <1ms

Nota: Il lookup findBySlug() su array è O(n), ma dato che:

  • Regioni = 20 → O(20) = trascurabile
  • Province per regione ≤ 12 → O(12) = trascurabile
  • Comuni per provincia = variabile, ma lookup in-memory velocissimo

Performance totale: ~0.5-1ms (uguale a HashMap flat)

3. Dispatcher Rewrite Rules

Obiettivo: Convertire URL SEO-friendly in selettori Sling

# dispatcher.any o httpd vhost

# Regione + Provincia + Comune
RewriteRule ^/locations/([a-z0-9-]+)/([a-z0-9-]+)/([a-z0-9-]+)\.html$ \
            /content/mysite/locations.$1.$2.$3.html [PT,L]

# Solo Regione + Provincia
RewriteRule ^/locations/([a-z0-9-]+)/([a-z0-9-]+)\.html$ \
            /content/mysite/locations.$1.$2.html [PT,L]

# Solo Regione
RewriteRule ^/locations/([a-z0-9-]+)\.html$ \
            /content/mysite/locations.$1.html [PT,L]

Esempi:

  • /locations/lombardia/milano/navigli.html/content/mysite/locations.lombardia.milano.navigli.html

  • /locations/emilia-romagna/bologna.html/content/mysite/locations.emilia-romagna.bologna.html

4. Sling Filter - Validazione e Session

@Component(
    service = Filter.class,
    property = {
        EngineConstants.SLING_FILTER_SCOPE + "=" + EngineConstants.FILTER_SCOPE_REQUEST,
        ServiceConstants.SERVICE_RANKING + ":Integer=1000"
    }
)
public class LocationDataFilter implements Filter {

    private static final String SESSION_KEY = "location.current.data";
    private static final String LOCATION_PAGE_PATH = "/content/mysite/locations";

    @Reference
    private LocationDataService locationService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        SlingHttpServletRequest slingRequest = (SlingHttpServletRequest) request;

        // Controlla se è una location page
        String path = slingRequest.getRequestPathInfo().getResourcePath();

        if (path.startsWith(LOCATION_PAGE_PATH)) {

            // Legge selettori: .lombardia.milano.navigli
            String[] selectors = slingRequest.getRequestPathInfo().getSelectors();

            if (selectors.length >= 1) {

                String regionSlug = selectors[0];
                String provinciaSlug = selectors.length >= 2 ? selectors[1] : null;
                String comuneSlug = selectors.length >= 3 ? selectors[2] : null;

                // Determina tipo location e valida
                LocationData locationData = null;

                if (comuneSlug != null) {
                    // Comune
                    if (locationService.exists(regionSlug, provinciaSlug, comuneSlug)) {
                        locationData = locationService.get(regionSlug, provinciaSlug, comuneSlug);
                    }
                } else if (provinciaSlug != null) {
                    // Provincia (logica simile)
                } else {
                    // Regione (logica simile)
                }

                if (locationData != null) {
                    // Salva in sessione per il template
                    slingRequest.getSession().setAttribute(SESSION_KEY, locationData);
                } else {
                    // Location non trovata → 404
                    ((SlingHttpServletResponse) response).sendError(404, "Location not found");
                    return;
                }
            }
        }

        chain.doFilter(request, response);
    }
}

5. Template Page Component

Una singola pagina template in /content/mysite/locations riutilizzata per tutte le 8130 location.

Sling Model:

@Model(adaptables = SlingHttpServletRequest.class)
public class LocationPageModel {

    private static final String SESSION_KEY = "location.current.data";

    @SlingObject
    private SlingHttpServletRequest request;

    public LocationData getLocationData() {
        return (LocationData) request.getSession().getAttribute(SESSION_KEY);
    }

    public String getPageTitle() {
        LocationData data = getLocationData();
        return data != null ? "Risorse a " + data.getComuneName() : "Location";
    }
}

Template HTL:

<div data-sly-use.model="com.mysite.models.LocationPageModel">
    <h1>{{COMUNE_NAME}}</h1>
    <p>Provincia: {{PROVINCIA_NAME}}</p>
    <p>Regione: {{REGIONE_NAME}}</p>

    <div class="resources">
        <!-- Componenti AEM standard per contenuti -->
    </div>
</div>

6. Sling Rewriter - Sostituzione Placeholder

Configurazione Rewriter Pipeline:

<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0"
          xmlns:jcr="http://www.jcp.org/jcr/1.0"
    jcr:primaryType="sling:Folder">

    <location-placeholder-rewriter
        jcr:primaryType="nt:unstructured"
        enabled="{Boolean}true"
        generatorType="htmlparser"
        order="{Long}1"
        serializerType="htmlwriter"
        transformerTypes="[location-placeholder-replacer]"
        paths="[/content/mysite/locations]"/>

</jcr:root>

Transformer Implementation:

@Component(
    service = TransformerFactory.class,
    property = {
        "pipeline.type=location-placeholder-replacer"
    }
)
public class LocationPlaceholderTransformerFactory implements TransformerFactory {

    @Override
    public Transformer createTransformer() {
        return new AbstractTransformer() {

            private LocationData locationData;

            @Override
            public void init(ProcessingContext context, ProcessingComponentConfiguration config) {
                // Recupera dati dalla sessione
                SlingHttpServletRequest request = context.getRequest();
                locationData = (LocationData) request.getSession()
                    .getAttribute("location.current.data");
            }

            @Override
            public void characters(char[] chars, int offset, int length) throws SAXException {

                if (locationData != null) {
                    String text = new String(chars, offset, length);

                    // Sostituisce placeholder
                    text = text.replace("{{COMUNE_NAME}}", locationData.getComuneName());
                    text = text.replace("{{PROVINCIA_NAME}}", locationData.getProvinciaName());
                    text = text.replace("{{REGIONE_NAME}}", locationData.getRegioneName());

                    char[] replaced = text.toCharArray();
                    super.characters(replaced, 0, replaced.length);
                } else {
                    super.characters(chars, offset, length);
                }
            }
        };
    }
}

Risultato finale HTML:

<!-- Template HTL aveva: -->
<h1>{{COMUNE_NAME}}</h1>

<!-- Browser riceve: -->
<h1>Navigli</h1>

Vantaggi Soluzione 1

Zero nodi JCR (solo il template /content/mysite/locations) ✅ Performance eccellenti (HashMap in-memory, <1ms lookup) ✅ SEO perfetto (URL puliti, HTML server-side) ✅ Dati ufficiali ISTAT (fonte autorevole e aggiornata) ✅ Semplice da implementare (usa feature native AEM) ✅ Dispatcher cacheable (una volta renderizzato, cache per ore)

Svantaggi Soluzione 1

Dati hard-coded nel bundle (update = redeploy) ❌ Non authorable (autori non vedono le pagine nel siteadmin) ❌ Sitemap manuale (serve servlet custom per generarlo)


Soluzione 2: Nodi /var con Tuple + Cache

Alternativa: Salvare i dati sotto /var invece che in-memory.

Struttura /var

/var/locations (nt:unstructured)

  /lombardia (nt:unstructured)
    name = "Lombardia"
    slug = "lombardia"

    /milano (nt:unstructured)
      name = "Milano"
      slug = "milano"
      comuni (String[]) = [
        "milano|Milano",
        "monza|Monza",
        "rho|Rho",
        "sesto-san-giovanni|Sesto San Giovanni",
        ...
      ]

Nodi totali: ~130 (20 regioni + 110 province) Comuni: Salvati come array di tuple "slug|Nome Ufficiale"

Service con Cache

@Component(service = LocationDataService.class)
public class LocationDataServiceImpl implements LocationDataService {

    @Reference
    private ResourceResolverFactory resolverFactory;

    // Cache solo le ~110 province
    private LoadingCache<String, ProvinciaData> provinciaCache;

    @Activate
    protected void activate() {
        this.provinciaCache = CacheBuilder.newBuilder()
            .maximumSize(150)
            .expireAfterWrite(1, TimeUnit.HOURS)
            .build(new CacheLoader<String, ProvinciaData>() {
                @Override
                public ProvinciaData load(String key) {
                    return loadProvinciaFromJcr(key);
                }
            });
    }

    @Override
    public LocationData get(String regionSlug, String provinciaSlug, String comuneSlug) {

        String key = regionSlug + "/" + provinciaSlug;
        ProvinciaData provincia = provinciaCache.get(key);

        // Cerca comune nell'array tuple
        String tuple = provincia.getComuni().stream()
            .filter(t -> t.startsWith(comuneSlug + "|"))
            .findFirst()
            .orElse(null);

        if (tuple != null) {
            String[] parts = tuple.split("\\|");
            return new LocationData(parts[1], provincia.getName(), regionSlug);
        }

        return null;
    }

    private ProvinciaData loadProvinciaFromJcr(String key) {
        try (ResourceResolver resolver = getServiceResolver()) {
            Resource res = resolver.getResource("/var/locations/" + key);

            if (res != null) {
                ValueMap props = res.getValueMap();
                return new ProvinciaData(
                    props.get("name", String.class),
                    Arrays.asList(props.get("comuni", String[].class))
                );
            }
        }
        return null;
    }
}

Script Import ISTAT → /var

public void importIstatToVar(InputStream jsonStream) throws Exception {
    try (ResourceResolver resolver = getServiceResolver()) {

        ObjectMapper mapper = new ObjectMapper();
        IstatData data = mapper.readValue(jsonStream, IstatData.class);

        Resource varRoot = resolver.getResource("/var");
        Resource locationsRoot = resolver.create(varRoot, "locations",
            Map.of("jcr:primaryType", "nt:unstructured"));

        for (RegionData region : data.getRegioni()) {

            Resource regionNode = resolver.create(locationsRoot, region.getSlug(),
                Map.of("name", region.getName(), "slug", region.getSlug()));

            for (ProvinciaData provincia : region.getProvince()) {

                // Crea array tuple "slug|name"
                String[] comuniTuples = provincia.getComuni().stream()
                    .map(c -> c.getSlug() + "|" + c.getName())
                    .toArray(String[]::new);

                resolver.create(regionNode, provincia.getSlug(),
                    Map.of(
                        "name", provincia.getName(),
                        "slug", provincia.getSlug(),
                        "comuni", comuniTuples
                    ));
            }
        }

        resolver.commit();
        LOG.info("✅ Import completato: ~130 nodi /var creati");
    }
}

Vantaggi Soluzione 2

Dati persistenti (non si perdono al restart) ✅ Update semplice (modifica nodi /var, no redeploy) ✅ Cache intelligente (solo province accedute) ✅ Backup automatico (parte del repository) ✅ Query JCR possibili (se necessario)

Svantaggi Soluzione 2

~130 nodi in più nel JCR (minimo, ma comunque presenti) ❌ Leggermente più lento del puro in-memory (cache miss = query JCR) ❌ Richiede script import iniziale


Soluzione 3: Backend Esterno + API Cache

Alternativa: Dati su backend esterno (microservice, DB, CMS headless).

Architettura

AEM → HTTP Client → Backend API → Database ISTAT
                         ↓
                    Cache Layer
                    (Redis/Memcached)

Service con HTTP Client

@Component(service = LocationDataService.class)
public class LocationDataServiceImpl implements LocationDataService {

    @Reference
    private HttpClient httpClient;

    private static final String API_BASE = "https://api.mycompany.com/locations";

    // Cache Guava in-memory
    private LoadingCache<String, LocationData> cache;

    @Activate
    protected void activate() {
        this.cache = CacheBuilder.newBuilder()
            .maximumSize(1000)  // Cache 1000 location più accedute
            .expireAfterWrite(6, TimeUnit.HOURS)
            .build(new CacheLoader<String, LocationData>() {
                @Override
                public LocationData load(String key) throws Exception {
                    return fetchFromBackend(key);
                }
            });
    }

    @Override
    public LocationData get(String regionSlug, String provinciaSlug, String comuneSlug) {
        String key = regionSlug + "/" + provinciaSlug + "/" + comuneSlug;
        return cache.get(key);
    }

    private LocationData fetchFromBackend(String key) throws Exception {

        String apiUrl = API_BASE + "/" + key + ".json";

        HttpResponse response = httpClient.execute(new HttpGet(apiUrl));

        if (response.getStatusLine().getStatusCode() == 200) {
            String json = EntityUtils.toString(response.getEntity());
            return new ObjectMapper().readValue(json, LocationData.class);
        }

        return null;
    }
}

Backend API Endpoint (esempio Node.js)

// Express.js API
app.get('/locations/:region/:provincia/:comune.json', async (req, res) => {
    const { region, provincia, comune } = req.params;

    // Query database
    const location = await db.query(
        'SELECT * FROM comuni WHERE slug = ? AND provincia_slug = ?',
        [comune, provincia]
    );

    if (location) {
        res.json(location);
    } else {
        res.status(404).json({ error: 'Not found' });
    }
});

Vantaggi Soluzione 3

Dati centralizzati (condivisibili tra più sistemi) ✅ Update real-time (no redeploy AEM) ✅ Scalabilità indipendente (backend può scalare separatamente) ✅ Dati dinamici (possono cambiare frequentemente)

Svantaggi Soluzione 3

Dipendenza esterna (se backend down, AEM fallisce) ❌ Latenza network (anche con cache, primo accesso lento) ❌ Complessità architetturale (più sistemi da mantenere) ❌ Costi infrastruttura (database, API server, cache Redis)


Confronto delle 3 Soluzioni

Aspetto Sol. 1: JSON In-Memory Sol. 2: Nodi /var Sol. 3: Backend API
Performance 🚀 Velocissimo (<1ms) ⚡ Molto veloce (2-5ms) 🐢 Dipende (10-50ms primo hit)
Nodi JCR ✅ Zero ⚠️ ~130 ✅ Zero
Update dati ♻️ Redeploy bundle ✏️ Update nodi ⚡ Real-time
Persistenza ❌ Si perde al restart ✅ Persistente ✅ Persistente
Dipendenze ✅ Zero ✅ Solo AEM ❌ Backend esterno
Complessità 🟢 Bassa 🟡 Media 🔴 Alta
Costi 💰 Zero 💰 Zero 💰💰 Infra esterna
Scalabilità ⚠️ Limitata (RAM) ✅ Buona ✅ Eccellente
Authoring ❌ No ❌ No ❌ No

Conclusioni e Raccomandazioni

Quando usare Soluzione 1 (JSON In-Memory)

Progetti piccoli/medi (~10k location max) ✅ Dati statici (cambiano raramente) ✅ Budget limitato (no infrastruttura extra) ✅ Time-to-market veloce (implementazione rapida)

Quando usare Soluzione 2 (Nodi /var)

Progetti enterprise con governance forte ✅ Dati che cambiano periodicamente (update mensili) ✅ Team preferisce JCR come source of truth ✅ Backup/restore importanti (parte del repository)

Quando usare Soluzione 3 (Backend API)

Dati condivisi tra più sistemi (AEM, mobile app, ecc.) ✅ Update frequenti real-time ✅ Scalabilità massima (milioni di location) ✅ Architettura microservices già esistente


La Mia Scelta Finale

Nel progetto reale ho scelto Soluzione 1 (JSON In-Memory) perché:

  1. Dati ISTAT stabili (aggiornati 1-2 volte l'anno)
  2. Performance critiche (e-commerce ad alto traffico)
  3. Budget limitato (no backend esterno)
  4. 8130 location = ~5 MB RAM (trascurabile)
  5. Implementazione rapida (2 giorni di sviluppo)

Risultati

  • 🚀 8130 pagine SEO con zero nodi JCR
  • <5ms TTFB (Time To First Byte)
  • 📈 +300% traffico organico in 6 mesi
  • 💾 Dispatcher cache hit >95%
  • 👨‍💻 Zero overhead per gli autori

Sitemap.xml Bonus

Per SEO, serve un sitemap con tutte le 8130 URL:

@Component(service = Servlet.class, ...)
public class LocationSitemapServlet extends SlingSafeMethodsServlet {

    @Reference
    private LocationDataService locationService;

    @Override
    protected void doGet(SlingHttpServletRequest request,
                        SlingHttpServletResponse response) throws IOException {

        response.setContentType("application/xml");
        PrintWriter out = response.getWriter();

        out.println("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
        out.println("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">");

        // Loop su tutte le location
        for (LocationData location : locationService.getAllLocations()) {
            out.printf("<url><loc>%s</loc><priority>0.7</priority></url>%n",
                location.getUrl());
        }

        out.println("</urlset>");
    }
}

Endpoint: /locations-sitemap.xml Submit to: Google Search Console


Key Takeaways

  1. 8000+ pagine SEO in AEM senza nodi JCR è possibile
  2. Dispatcher rewrite + Selettori = URL puliti
  3. Sling Rewriter = feature potente ma poco usata
  4. ISTAT = fonte ufficiale per dati geografici Italia
  5. In-memory HashMap = performance imbattibili
  6. Scelta soluzione dipende da: budget, update frequency, scalabilità

Hai implementato soluzioni simili? Quale approccio hai scelto? Condividi la tua esperienza nei commenti!

Articoli correlati: