Mehr Tempo für die News-Liste: Mit einem kleinen JS-Snippet und zwei Template-Tweaks tauschen wir nur den Listen-Block aus. Ergebnis: spürbar schnellere Filter, saubere History und eine deutlich bessere User Experience.
AJAX-Filter für EXT:news in TYPO3 12

1) List-Template um einen stabilen Container erweitern
Wir überschreiben das News-Listen-Template und packen die Ausgabe in einen fixen Wrapper #news-container
, den wir später per AJAX austauschen.
TypoScript (Setup) – Override-Pfade:
plugin.tx_news {
view {
templateRootPaths.10 = EXT:site_package/Resources/Private/Extensions/News/Templates/
partialRootPaths.10 = EXT:site_package/Resources/Private/Extensions/News/Partials/
layoutRootPaths.10 = EXT:site_package/Resources/Private/Extensions/News/Layouts/
}
}
EXT:site_package/Resources/Private/Extensions/News/Templates/News/List.html
:
<f:layout name="General" />
<f:section name="content">
<div id="news-container">
<f:if condition="{news}">
<f:for each="{news}" as="newsItem">
<f:render partial="List/Item" arguments="{newsItem: newsItem, settings: settings}" />
</f:for>
<!-- Gib deiner Pagination einen klaren Selektor -->
<nav class="news-pagination">
<f:render partial="List/Pagination" arguments="{pagination: pagination, paginator: paginator, settings: settings}" />
</nav>
</f:if>
</div>
</f:section>
Wichtig: Der gleiche #news-container
muss auf jeder Seite/Ansicht vorhanden sein, deren HTML du lädst (Kategorie, Pagination, etc.).
2) Kategorie-Links sauber erzeugen (mit cHash)
Erzeuge deine Kategorie-Links immer mit <f:link.page>
und additionalParams
, damit TYPO3 korrekte URLs inkl. cHash
baut.
Beispiel Kategorie-Menü (im eigenen Template/Partial):
<ul class="news-categories">
<f:for each="{categories}" as="category">
<li>
<f:link.page
pageUid="{settings.listPid}"
class="category-link"
additionalParams="{
tx_news_pi1: {
overwriteDemand: {
categories: category.uid,
includeSubCategories: 1,
categoryConjunction: 'or'
}
}
}"
>{category.title}</f:link.page>
</li>
</f:for>
</ul>
3) JavaScript (Delegation + pushState)
Das folgende Skript:
- fängt Klicks auf Kategorien und auf Pagination-Links ab,
- lädt die Ziel-URL per
fetch()
, - ersetzt nur den inneren HTML-Inhalt von
#news-container
, - aktualisiert die Browser-Adresse via
history.pushState
, - reagiert auf den Zurück-Button mittels
popstate
.
Kommentare im Code sind absichtlich auf Englisch. Zur Zeile-für-Zeile-Erklärung nutze ich Marker
[1]
,[2]
, …, auf die ich direkt darunter eingehe.
<script>
// [1] Run script after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// [2] Grab the container that we will replace
const container = document.querySelector('#news-container');
if (!container) return; // [3] Safety check
// [4] Helper to fetch a URL and inject only the inner HTML of #news-container
async function loadIntoContainer(url, addToHistory = true) {
try {
// [5] Fetch the full page HTML (server-side rendering stays intact)
const res = await fetch(url, { headers: { 'X-Requested-With': 'fetch' } });
if (!res.ok) throw new Error('HTTP ' + res.status); // [6] Basic error handling
// [7] Read HTML as text
const html = await res.text();
// [8] Create a temporary DOM to query the new #news-container
const tmp = document.createElement('div');
tmp.innerHTML = html;
// [9] Find the #news-container inside the response
const newContent = tmp.querySelector('#news-container');
if (!newContent) throw new Error('Missing #news-container in response');
// [10] Replace current container content
container.innerHTML = newContent.innerHTML;
// [11] Optionally push new URL into history (so the address bar updates)
if (addToHistory) history.pushState({ ajax: true }, '', url);
} catch (e) {
// [12] Log AJAX errors for debugging
console.error('AJAX error:', e);
}
}
// [13] Global click delegation (works for dynamic content too)
document.addEventListener('click', (ev) => {
// [14] Find nearest link for the clicked element
const a = ev.target.closest('a');
if (!a) return;
// [15] Category links: intercept and load via AJAX
if (a.classList.contains('category-link')) {
ev.preventDefault();
// [16] Toggle "active" state in the category menu
document.querySelectorAll('.category-link').forEach(el => el.classList.remove('active'));
a.classList.add('active');
// [17] Load the target URL into #news-container
loadIntoContainer(a.href);
return;
}
// [18] Pagination links: intercept by container class (see List.html)
if (a.closest('.news-pagination')) {
ev.preventDefault();
loadIntoContainer(a.href);
return;
}
});
// [19] Handle back/forward navigation
window.addEventListener('popstate', () => {
// [20] Reload current URL into container without pushing a new history entry
loadIntoContainer(location.href, /* addToHistory */ false);
});
});
</script>
Zeile-für-Zeile-Erklärung (Marker [n]
):
- Registriert den Startpunkt: Script läuft, sobald der DOM aufgebaut ist.
- Sucht den Ziel-Container, den wir dynamisch ersetzen.
- Falls kein Container vorhanden ist, bricht das Script sauber ab (kein Fehler).
- Hilfsfunktion: Lädt eine URL und ersetzt danach den Inhalt von
#news-container
. - Holt das komplette Seiten-HTML (Server-Rendering bleibt 100 % erhalten; SEO-sicher).
X-Requested-With
ist optional, kann serverseitig für Logging genutzt werden. - Wenn der Server keinen 2xx-Status liefert, werfen wir einen Fehler (landet in
catch
). - Liest den Response-Body als reinen Text (HTML).
- Baut einen temporären DOM-Knoten, um im geladenen HTML selektieren zu können.
- Greift auf den
#news-container
in der geladenen Seite zu. Wichtig: Er muss in jeder Zielseite existieren. - Ersetzt den inneren HTML-Inhalt des bestehenden Containers durch den aus der geladenen Antwort.
- Aktualisiert die Adresszeile über
history.pushState
, damit Links teilbar/Bookmark-fähig bleiben (Progressive Enhancement). - Einheitliche Fehlerbehandlung (sichtbar in der Console).
- Event-Delegation: Statt allen Links separat Handler zu geben, horchen wir global und reagieren nur auf relevante Klicks – das funktioniert auch nach DOM-Austausch.
- Ermittelt das geklickte
<a>
(oder den nächstliegenden Link-Ancestor), damit auch Klicks auf Icons/Text innerhalb von Links funktionieren. - Kategorie-Links: Standard-Navigation verhindern, stattdessen per AJAX nachladen.
- Visuelles Umschalten der aktiven Kategorie (Klasse
active
). - Lädt die URL (inkl. richtiger
cHash
, weil der Link via<f:link.page>
gebaut wurde) und ersetzt#news-container
. - Pagination-Links: Erkennen wir per Nähe zu
.news-pagination
(siehe Template), laden ebenfalls per AJAX. - Lauschen auf Browser-Navigation zurück/vor.
- Lädt die aktuelle URL erneut in den Container, ohne einen neuen History-Eintrag zu erzeugen (damit History korrekt bleibt).
4) Optional: «leichter» Endpunkt nur für die Liste
Wenn du statt der ganzen Seite nur die Listen-Teilansicht laden willst, kannst du einen typeNum
anlegen, der nur das Plugin rendert. So transferierst du weniger HTML.
TypoScript:
ajax.newsList = PAGE
ajax.newsList {
typeNum = 165099
10 =< tt_content.list.20.news_news # render the list plugin only
config {
disableAllHeaderCode = 1
}
}
JS-Anpassung (nur eine Zeile im Click-Handler ändern):
// Before calling loadIntoContainer:
const url = new URL(a.href, location.origin);
url.searchParams.set('type', '165099'); // use partial list endpoint
loadIntoContainer(url.toString());
5) Wichtige Hinweise / Stolpersteine
- cHash: Links immer via
<f:link.page>
+additionalParams
erzeugen (nicht „hart“ basteln). - Gleicher Container: Stelle sicher, dass jede Zielseite denselben
#news-container
enthält. - Pagination-Selektor: Achte auf
.news-pagination
im Template, damit das Delegations-Matching greift. - JS global halten: Packe das Skript ausserhalb des dynamisch ersetzten HTMLs (z. B. in dein Site-Bundle), sonst verlierst du Event-Handler.
- SEO/No-JS: Ohne JS funktionieren die Links normal (Full-Reload). Mit JS bekommst du schnelle Teil-Updates und aktualisierte URL per
pushState
.
Java Script