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.html
✅ Contenuti 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
- Dati ISTAT: Scaricare CSV ufficiale ISTAT con regioni/province/comuni
- Conversione JSON: Script per convertire CSV → JSON strutturato
- Service in-memory: Caricare JSON in RAM all'avvio di AEM
- Dispatcher rewrite: Convertire URL puliti in selettori Sling
- Filter + Session: Validare location e passare dati via sessione
- 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'Aosta→valle-d-aostaReggio nell'Emilia→reggio-emiliaBolzano/Bozen→bolzano-bozenForlì-Cesena→forli-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é:
- ✅ Dati ISTAT stabili (aggiornati 1-2 volte l'anno)
- ✅ Performance critiche (e-commerce ad alto traffico)
- ✅ Budget limitato (no backend esterno)
- ✅ 8130 location = ~5 MB RAM (trascurabile)
- ✅ 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
- 8000+ pagine SEO in AEM senza nodi JCR è possibile
- Dispatcher rewrite + Selettori = URL puliti
- Sling Rewriter = feature potente ma poco usata
- ISTAT = fonte ufficiale per dati geografici Italia
- In-memory HashMap = performance imbattibili
- Scelta soluzione dipende da: budget, update frequency, scalabilità
Hai implementato soluzioni simili? Quale approccio hai scelto? Condividi la tua esperienza nei commenti!
Articoli correlati: