Kevins Einstiegsartikel hört an einer spannenden Stelle auf. Du hast im Adminbereich einen eigenen Inhaltstyp angelegt, und Kevin schreibt, dass eine neue Sammlung erst mit einer Seite in der Vorlage auf der Website erscheint. Das sei der Teil, bei dem Code ins Spiel kommt. Wie viel Code ist das eigentlich, und was musst du dabei beachten?
Weniger, als du vielleicht befürchtest. Ich baue in diesem Artikel ein kleines Projekt von Anfang bis Ende: einen Inhaltstyp für Veranstaltungen, zwei Termine darin, eine Übersichtsseite mit allen kommenden Terminen, eine Detailseite für jeden einzelnen und einen Eintrag im Menü. Zum Schluss erkläre ich dir, in welcher Reihenfolge du das auf eine Site bringst, die schon online ist.
Ich bin alles mit EmDash 1.1.0 auf einer frischen Blog-Vorlage durchgegangen, in der Node-Fassung von npm create emdash@latest. Der Code in diesem Artikel ist genau der, der bei mir lief, und die Screenshots stammen aus dieser lokalen Site. Die Cloudflare-Fassung der Vorlage bringt dieselben Seiten und dasselbe Layout mit, dort ausprobiert habe ich den Code aber nicht.
Den Inhaltstyp anlegen
Fang im Adminbereich unter Inhaltstypen an und klick auf Neuer Inhaltstyp. Als Bezeichnung trägst du „Veranstaltung“ im Singular und „Veranstaltungen“ im Plural ein. Die Bezeichnungen sieht deine Redaktion, sie dürfen also deutsch sein, und du kannst sie später jederzeit ändern.
Beim Slug darunter lohnt es sich, kurz innezuhalten. Der Adminbereich schlägt aus dem Plural veranstaltungen vor. Ich überschreibe das mit events, weil der Slug in jeder Abfrage im Code steht und der restliche Code bei mir englisch ist. Wichtiger als die Sprache ist, dass du ihn jetzt festlegst. Später lässt der Adminbereich den Slug eines Inhaltstyps nicht mehr ändern. Warum, steht in meinem Artikel EmDash-Admin im Alltag.
Zwei Einstellungen sind für unser Projekt wichtig. Routingfähig ist schon eingeschaltet und sorgt dafür, dass jeder Termin einen Slug haben muss, bevor du ihn veröffentlichst. Den brauchen wir gleich für die Adresse der Detailseite. Ins URL-Muster trägst du /events/{slug} ein. Damit weiß der Adminbereich, wo ein Termin auf der Website liegt, und der Knopf Live-Ansicht im Editor führt später genau dorthin. Unter Funktionen sind Entwürfe und Revisionen vorausgewählt, das passt. Wenn du magst, gibst du noch das Icon calendar-blank an, dann bekommt der Eintrag in der Seitenleiste einen Kalender.
Nach Inhaltstyp erstellen landest du auf der Seite des neuen Typs. Rechts siehst du sechs Systemfelder wie ID, Slug und Status, aber noch kein einziges eigenes Feld. Auch einen Titel legt EmDash nicht von selbst an.
Sechs Felder für einen Termin
Die Felder fügst du mit Feld hinzufügen an, eins nach dem anderen. Für einen Termin habe ich diese sechs gewählt:
Bezeichnung | Slug | Feldtyp | Schalter |
|---|---|---|---|
Titel |
| Kurztext | Erforderlich |
Beginn |
| Datum & Uhrzeit | Erforderlich, Indexiert |
Ort |
| Kurztext | |
Kurzbeschreibung |
| Langtext | |
Bild |
| Bild | |
Link |
| URL |
Auch hier schlägt der Dialog den Slug aus der Bezeichnung vor, aus „Beginn“ wird beginn. Ich trage die englischen Slugs aus der Tabelle von Hand ein, denn genau diese Namen fragt der Code gleich ab. Slug und Feldtyp stehen nach dem Anlegen fest, überleg dir beides also, bevor du klickst. Was sonst noch dazugehört, steht im Admin-Artikel.

Beim Feld Beginn schalte ich Indexiert ein. Die Übersichtsseite sortiert und filtert nach diesem Feld, und für genau solche Felder empfiehlt die Doku einen Index. Ein Feld, das du nur anzeigst, braucht keinen.
Bevor du den ersten Termin einträgst, schau noch unter Einstellungen → Allgemein nach der Zeitzone. Ein Feld vom Typ Datum & Uhrzeit speichert einen Zeitpunkt in UTC. Was die Redaktion in den Kalender tippt, rechnet der Adminbereich mit dieser Zeitzone um, und die steht nach der Installation auf UTC. Ich habe sie auf Europe/Berlin gestellt. Danach wurde aus 18:00 Uhr im Editor 2026-11-12T17:00:00.000Z in der Datenbank, und die Website zeigt wieder 18:00 Uhr an.

Zwei Termine eintragen
In der Seitenleiste steht jetzt unter Inhalt der Punkt Veranstaltungen. Dort legst du den ersten Termin an. Ich habe einen Workshop am 12. November um 18:00 Uhr eingetragen, Ort und Kurzbeschreibung ergänzt und mit Bild auswählen ein Foto aus der Medienbibliothek genommen. Rechts unter URL & Sprache vergibst du den Slug, bei mir workshop-website-pflege. Dann Speichern und Jetzt veröffentlichen, und der Termin ist live.

Der zweite Termin ist ein Tag der offenen Tür am 5. Dezember. Zum Testen habe ich außerdem ein Sommerfest im Juli angelegt, also in der Vergangenheit. An diesem Termin siehst du gleich, ob der Filter auf der Übersichtsseite greift.
Wenn du jetzt /events im Browser öffnest, bekommst du trotzdem eine 404-Seite. Die Termine liegen in der Datenbank, aber es gibt noch keine Seite, die sie anzeigt. Jetzt kommt der Code.
Die Übersichtsseite
Astro macht aus jeder Datei unter src/pages eine Adresse. Für die Übersicht legst du den Ordner src/pages/events an und darin die Datei index.astro. Als Vorbild dienen die beiden Seiten in src/pages/posts, mit denen die Blog-Vorlage ihre Beiträge anzeigt. Möchtest du deutsche Adressen, nennst du den Ordner veranstaltungen und passt URL-Muster und Links entsprechend an.
---
import { getEmDashCollection, getSiteSettings } from "emdash";
import { Image } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const now = new Date().toISOString();
const { entries: events, error, cacheHint } = await getEmDashCollection("events", {
locale: Astro.currentLocale,
where: { starts_at: { gte: now } },
orderBy: { starts_at: "asc" },
});
if (error) {
console.error("Failed to load events:", error);
return new Response("Unable to load events", { status: 500 });
}
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
const { timezone = "UTC" } = await getSiteSettings();
const formatDate = (iso: string) =>
new Date(iso).toLocaleString("de-DE", {
dateStyle: "full",
timeStyle: "short",
timeZone: timezone,
});
---
<Base title="Veranstaltungen" description="Alle kommenden Termine">
<div class="events-page">
<h1>Veranstaltungen</h1>
{events.length === 0 && <p>Gerade stehen keine Termine an.</p>}
<ul class="events">
{events.map((event) => (
<li class="event">
{event.data.image?.id && (
<Image image={event.data.image} class="event-image" />
)}
<div>
<time datetime={event.data.starts_at}>
{formatDate(event.data.starts_at)}
</time>
<h2>
<a href={`/events/${event.id}`}>{event.data.title}</a>
</h2>
{event.data.location && <p class="location">{event.data.location}</p>}
</div>
</li>
))}
</ul>
</div>
</Base>
<style>
.events-page {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.events {
list-style: none;
padding: 0;
display: grid;
gap: var(--spacing-8);
}
.event {
display: grid;
grid-template-columns: 12rem 1fr;
gap: var(--spacing-6);
align-items: start;
}
.event :global(.event-image) {
width: 100%;
height: auto;
border-radius: var(--radius-md, 8px);
}
.event time,
.location {
color: var(--color-muted);
}
</style>Die ganze Arbeit steckt in der einen Abfrage. getEmDashCollection bekommt den Slug des Inhaltstyps und drei Optionen. where filtert mit gte auf alle Termine, deren Beginn nach dem aktuellen Zeitpunkt liegt. Das klappt mit einem einfachen Textvergleich, weil EmDash jeden Zeitpunkt im selben ISO-Format in UTC speichert, das auch toISOString() liefert. orderBy sortiert aufsteigend nach dem Beginn, sodass der nächste Termin oben steht. In beiden Fällen nennst du den Slug des Feldes und nicht die Bezeichnung. Filter und Sortierung erledigt die Datenbank, dabei hilft ihr der Index, den du beim Feld Beginn eingeschaltet hast.
locale: Astro.currentLocale sieht auf einer einsprachigen Site überflüssig aus. Ohne i18n-Konfiguration ist der Wert undefined, ich habe das nachgeprüft, und EmDash nimmt dann die Standardsprache. Schaltest du später eine zweite Sprache ein, fragt die Seite aber schon die richtige ab. Wir geben die Sprache deshalb bei jeder Abfrage mit.
Ein Detail hat mich beim Testen überrascht. Vertippst du dich bei einem Slug, etwa beim Feld im where oder beim Namen des Inhaltstyps, meldet die Abfrage keinen Fehler, sondern liefert einfach eine leere Liste. Bleibt deine Seite leer, obwohl Termine veröffentlicht sind, vergleich also zuerst die Slugs im Code mit denen im Adminbereich.
Der Rest folgt dem Muster der Vorlage. error ist nur bei einem echten Fehler gesetzt und nicht bei einer leeren Liste. Den cacheHint reichst du an Astros Cache weiter, falls er aktiv ist. Die Zeitzone holt sich die Seite aus den Einstellungen, damit sie dieselbe Uhrzeit zeigt, die die Redaktion eingetippt hat. Ein Bildfeld enthält ein Objekt mit Media-ID, Maßen und Alt-Text und keine URL. Deshalb prüfst du image?.id und renderst es mit <Image> aus emdash/ui, das dir auch gleich ein srcset für verschiedene Bildschirmgrößen baut.
Der Link zur Detailseite nutzt event.id. Das ist bei EmDash der Slug des Eintrags, also genau der Teil, den die Adresse braucht. Und das Sommerfest aus dem Juli taucht auf der Übersicht nicht auf, der Filter greift.

Die Detailseite
Die Titel auf der Übersicht verlinken jetzt auf Seiten, die es noch nicht gibt. Die zweite Datei kommt in denselben Ordner und heißt [slug].astro. Die eckigen Klammern sagen Astro, dass dieser Teil der Adresse variabel ist und im Code als Astro.params.slug ankommt.
---
import { decodeSlug, getEmDashEntry, getSiteSettings } from "emdash";
import { Image } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/404");
const { entry: event, error, cacheHint } = await getEmDashEntry("events", slug, {
locale: Astro.currentLocale,
});
if (error) {
console.error("Failed to load event:", error);
return new Response("Unable to load event", { status: 500 });
}
if (!event) return Astro.rewrite("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
const { timezone = "UTC" } = await getSiteSettings();
const startsAt = new Date(event.data.starts_at).toLocaleString("de-DE", {
dateStyle: "full",
timeStyle: "short",
timeZone: timezone,
});
---
<Base
title={event.data.title}
description={event.data.summary}
content={{ collection: "events", id: event.data.id, slug }}
>
<article class="event-page">
{event.data.image?.id && <Image image={event.data.image} priority />}
<h1>{event.data.title}</h1>
<p class="when">
<time datetime={event.data.starts_at}>{startsAt}</time>
{event.data.location && <> · {event.data.location}</>}
</p>
{event.data.summary && <p>{event.data.summary}</p>}
{event.data.link && <p><a href={event.data.link}>Zur Anmeldung</a></p>}
<p><a href="/events">Alle Veranstaltungen</a></p>
</article>
</Base>
<style>
.event-page {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.event-page :global(img) {
width: 100%;
height: auto;
border-radius: var(--radius-md, 8px);
}
.when {
color: var(--color-muted);
}
</style>getEmDashEntry holt einen einzelnen Eintrag über seinen Slug. Gibt es keinen passenden, antwortet die Seite mit Astro.rewrite("/404"), und der Browser bekommt den Status 404. In der Vorlage, die npm create emdash mit 1.1.0 herunterlädt, steht an dieser Stelle noch Astro.redirect("/404"). Das schickt den Browser erst mit Status 302 weiter, bevor er auf der 404-Seite landet. Ich nehme deshalb rewrite, so wie die Doku zum Abfragen von Inhalten. Im EmDash-Repository ist die Vorlage schon umgestellt, die heruntergeladene Fassung zieht mit dem nächsten Abgleich nach.
Hier begegnet dir die zweite ID. Ein Eintrag hat zwei, und sie haben verschiedene Aufgaben. event.id ist der Slug, den du auf der Übersicht für den Link benutzt hast. event.data.id ist die ULID, also die feste ID in der Datenbank, die auf der Seite des Inhaltstyps als Systemfeld „ID“ steht. Die ULID bleibt gleich, auch wenn die Redaktion den Slug ändert. Deshalb bekommt das Layout im content-Prop die ULID, und auch Helfer wie getTermsForEntries erwarten sie. Für Adressen nimmst du immer entry.id, für alles, was einen Eintrag dauerhaft wiederfinden soll, entry.data.id.
Die Detailseite filtert bewusst nicht nach dem Datum. Das Sommerfest ist unter /events/sommerfest weiterhin erreichbar, alte Links ins Archiv funktionieren also. Die Kurzbeschreibung gebe ich als einfachen Text aus. Willst du Absätze, Listen oder Links darin, nimm als Feldtyp Rich Text und gib das Feld mit <PortableText> aus emdash/ui aus.

Der Link im Menü
Die Seiten stehen, aber noch findet niemand hin. Das Menü der Blog-Vorlage pflegst du im Adminbereich unter Menüs. Öffne dort Primary Navigation mit Bearbeiten und wähle Benutzerdefinierten Link hinzufügen. Als Bezeichnung trägst du „Veranstaltungen“ ein, als URL /events, dann Hinzufügen. Der Menüpunkt ist sofort gespeichert und erscheint in der Kopfzeile und im Footer der Vorlage. Inhalt hinzufügen wäre hier der falsche Weg, damit verlinkst du einen einzelnen Eintrag und nicht die Übersicht.

Auf die Live-Site: erst der Adminbereich, dann der Code
Lokal läuft jetzt alles. Auf deiner Live-Site gibt es den Inhaltstyp aber noch gar nicht, denn EmDash speichert Inhaltstypen und Felder in der Datenbank und nicht im Code. Was du lokal angelegt hast, liegt in deiner lokalen Datenbank und wandert mit keinem Deployment mit. Die Hintergründe dazu, auch zur Rolle von seed/seed.json, findest du im Admin-Artikel. Für unser Projekt ergibt sich daraus diese Reihenfolge:
- Im Adminbereich der Live-Site legst du den Inhaltstyp mit denselben Slugs und Schaltern an, also
eventsund die sechs Felder aus der Tabelle, und stellst die Zeitzone ein. - Dann mergst du die beiden Seiten. Nimm die Datei
emdash-env.d.tsmit in den Commit. Der Dev-Server hat sie neu geschrieben, als ich die Felder angelegt habe, und seitdem kennt TypeScript den TypEventmitstarts_atund allen anderen Feldern. - Die Termine kann die Redaktion schon nach dem ersten Schritt eintragen. Solange die Seiten fehlen, sieht sie niemand.
- Den Menüpunkt setzt du zuletzt, wenn
/eventsonline ist.
Was passiert, wenn du die Reihenfolge umdrehst, habe ich ausprobiert. Eine Abfrage auf einen Inhaltstyp, den es nicht gibt, wirft keinen Fehler, sondern liefert eine leere Liste. Die Übersicht zeigt dann „Gerade stehen keine Termine an“, und jede Detailseite endet in der 404. Kaputt geht also nichts, aber es steht eine leere Seite online, und mit dem Menüpunkt schickst du deine Besucher direkt dorthin.
Noch ein Hinweis, falls du Astros Cache einschaltest. Die Vorlage reicht den cacheHint nur weiter, gecacht wird erst, wenn du einen Cache-Provider und eine Cache-Dauer für die Route festlegst. Veröffentlichst, änderst oder löschst du dann einen Termin im Adminbereich, sagt EmDash dem Cache Bescheid, und der verwirft die gespeicherten Seiten deiner Veranstaltungen. So steht es im Quelltext von EmDash 1.1.0, und das gilt für Astros Speicher-Cache genauso wie für den Cache von Cloudflare. Dass ein Termin vorbei ist, löst dagegen nichts aus. Ich habe das mit einem Testtermin nachgestellt, der zwei Minuten nach dem ersten Aufruf begann. Mit einer Stunde Cache-Dauer stand er auch nach seinem Beginn noch auf der Übersicht, während die Seite ohne Cache ihn schon ausgeblendet hatte. Wähl die Cache-Dauer für /events also so kurz, dass ein vergangener Termin nicht zu lange stehen bleibt. Wie du den Cache auf Cloudflare einrichtest, zeige ich in Edge-Caching für EmDash auf Cloudflare.
Fazit
Zurück zur Frage vom Anfang. Der Code, der eine neue Sammlung auf die Website bringt, besteht aus zwei Astro-Seiten. Die Übersicht holt mit getEmDashCollection alle kommenden Termine, gefiltert und sortiert in der Datenbank. Die Detailseite holt mit getEmDashEntry einen Termin über seinen Slug. Mehr braucht es nicht, solange drei Dinge stimmen. Die Slugs im Code müssen genau denen im Adminbereich entsprechen, denn ein Tippfehler bricht nichts ab, er liefert nur leere Listen. Für Links nimmst du entry.id, für dauerhafte Verweise entry.data.id. Und auf der Live-Site legst du erst den Inhaltstyp im Adminbereich an und mergst danach den Code.
Mit Veranstaltungen hast du das Muster einmal komplett durchgespielt. Ob du als Nächstes Projekte, Rezepte oder ein Team-Verzeichnis baust, die Schritte bleiben dieselben.

Kommentare
No comments yet
Erstkommentare erscheinen nach unserer Freigabe. Was mit deinen Angaben passiert