Communitygithub.com

asolis87/astro-konventionen

Astro-Konventionen für Landing Pages, Content-Seiten und SSG-Projekte bei Vera Sesiom. Standardmäßig kein JavaScript, Vue Islands für Interaktivität und eine inhaltsorientierte Architektur.

Was ist astro-konventionen?

Dieses Skill definiert die Astro-Konventionen von Vera Sesiom für den Bau von Landing Pages, Marketing-Seiten, Blogs, Dokumentationen und anderen content-lastigen statischen Websites. Es legt einen klaren Fokus auf Performance und SEO durch standardmäßig null JavaScript (Zero JS). Interaktivität wird nur dort eingesetzt, wo sie wirklich nötig ist, und zwar durch Vue-Inseln (Islands), die nur bei Bedarf hydriert werden. Die Struktur folgt dem Prinzip der „Screaming Architecture“, bei der Ordner und Dateinamen die Fachdomäne widerspiegeln und nicht das verwendete Framework. Das Skill enthält detaillierte Richtlinien zur Entscheidungsfindung, wann Astro und wann Vue zum Einsatz kommt, eine vorgegebene Projektstruktur sowie Patterns für Astro-Komponenten, Layouts, Content Collections und SEO. Es beschreibt, wie Vue-Komponenten als interaktive Inseln eingebunden werden und listet die verschiedenen Client-Direktiven auf, die steuern, wann das JavaScript für eine Komponente geladen wird. Außerdem werden die wichtigsten Regeln für TypeScript, Tailwind CSS, Bildoptimierung und statisches Rendering (SSG) festgehalten. Ziel ist es, konsistente und hochperformante Websites nach dem Vorbild von Vera Sesiom zu erstellen.

Funktioniert mit~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/asolis87/complementary-food-app/tree/HEAD/.agents/skills/astro-landing

In Ihrer bevorzugten KI fragen

Öffnet einen neuen Chat, in dem dieser Agent-Skill bereits geladen ist.

Dokumentation

Wann verwenden

  • Landing Pages und Marketing-Seiten
  • Unternehmenswebsites
  • Content-lastige Seiten (Blogs, Dokumentationen)
  • Jedes Projekt, bei dem SEO und Performance entscheidend sind
  • Seiten, die größtenteils statisch sind und nur minimale Interaktivität benötigen
  • Seiten, die schnelle Ladezeiten und gute Core Web Vitals erfordern

NICHT für vollwertige Web-Apps – verwende vue-frontend für interaktive Anwendungen (Dashboards, CRUD-Apps, Echtzeit-Apps).


Grundlegende Regeln

  1. Standardmäßig kein JavaScript – Astro liefert KEIN JavaScript aus, es sei denn, du fügst eine Client-Direktive hinzu
  2. Immer TypeScript – wie bei allen Vera Sesiom-Projekten, im Strict-Modus
  3. Content Collections für strukturierte Inhalte – Blogbeiträge, Dokumentationen, Fallstudien
  4. Insel-Architektur – Vue/React-Komponenten nur dort, wo Interaktivität benötigt wird
  5. Standardmäßig statische Ausgabe (SSG) – SSR nur, wenn dynamische Inhalte es erfordern
  6. Tailwind CSS für das Styling – wie beim Vue-Frontend
  7. Screaming Architecture – Ordner „schreien“ die Fachdomäne, nicht das Framework

Entscheidungskriterien: Astro vs. Vue

Wann Astro verwendenWann Vue verwenden
Landing Page, Marketing-SeiteWebanwendung (Dashboard, Admin)
Content-lastig (Blog, Docs)Hochinteraktive UI
SEO ist entscheidendEchtzeit-Funktionen erforderlich
Meistens statische InhalteKomplexes State-Management
Minimale Interaktivität (Formulare, Karussells)Vollständige SPA-Navigation
Kein JS ist ein FeatureVorteile des Vue-Ökosystems sind wichtig

Regel: Wenn die Seite Inhalte bietet, die Leute LESEN → Astro. Wenn es eine App ist, die Leute NUTZEN → Vue.


Projektstruktur (Screaming Architecture)

apps/landing/   (oder apps/website/)
├── src/
│   ├── pages/                    # Dateibasiertes Routing (.astro-Dateien)
│   │   ├── index.astro           # Startseite
│   │   ├── about.astro           # Über uns
│   │   ├── pricing.astro         # Preise
│   │   ├── blog/
│   │   │   ├── index.astro      # Blog-Liste
│   │   │   └── [slug].astro      # Blogbeitrag (dynamisch)
│   │   └── contact.astro
│   ├── layouts/                  # Seitenlayouts
│   │   ├── BaseLayout.astro      # Basis-HTML-Struktur
│   │   ├── BlogLayout.astro      # Blogbeitrag-Layout
│   │   └── LandingLayout.astro   # Landing-Page-Layout
│   ├── components/               # Astro-Komponenten (standardmäßig statisch)
│   │   ├── sections/             # Seitenabschnitte
│   │   │   ├── Hero.astro
│   │   │   ├── Features.astro
│   │   │   ├── Pricing.astro
│   │   │   ├── Testimonials.astro
│   │   │   └── CTA.astro
│   │   ├── ui/                   # Wiederverwendbare UI-Atome
│   │   │   ├── Button.astro
│   │   │   ├── Card.astro
│   │   │   ├── Badge.astro
│   │   │   └── Input.astro
│   │   └── interactive/          # Vue/React-Inseln
│   │       ├── ContactForm.vue   # Formular mit Validierung
│   │       ├── Carousel.vue      # Bild-Karussell
│   │       └── MobileMenu.vue    # Mobile Navigation
│   ├── content/                  # Content Collections
│   │   ├── blog/                 # Markdown/MDX-Blogbeiträge
│   │   │   ├── post-1.md
│   │   │   └── post-2.mdx
│   │   └── config.ts             # Collection-Schema (Zod)
│   ├── styles/                   # Globale Styles
│   │   └── global.css            # Tailwind-Imports
│   ├── assets/                   # Optimierte Bilder, Schriftarten
│   │   ├── images/
│   │   └── fonts/
│   └── utils/                    # Hilfsfunktionen
│       ├── date-format.ts
│       └── seo.ts
├── public/                       # Statische Assets (keine Verarbeitung)
│   ├── favicon.ico
│   ├── robots.txt
│   └── fonts/
├── astro.config.mjs              # Astro-Konfiguration
├── tailwind.config.mjs           # Tailwind-Konfiguration
├── tsconfig.json
└── package.json

Astro-Komponentenmuster

Jede .astro-Datei besteht aus zwei Teilen: Frontmatter (serverseitig) und Template (HTML).

---
// src/components/ui/Button.astro
// Frontmatter: läuft serverseitig, KEIN Browser-JS

export interface Props {
  variant?: 'primary' | 'secondary' | 'outline'
  size?: 'sm' | 'md' | 'lg'
  href?: string
}

const {
  variant = 'primary',
  size = 'md',
  href,
  ...rest
} = Astro.props

const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors'
const variantClasses = {
  primary: 'bg-blue-600 text-white hover:bg-blue-700',
  secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200',
  outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50'
}
const sizeClasses = {
  sm: 'px-3 py-1.5 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg'
}

const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`
---

{href ? (
  <a href={href} class={classes} {...rest}>
    <slot />
  </a>
) : (
  <button class={classes} {...rest}>
    <slot />
  </button>
)}

Wichtige Punkte:

  • Das Frontmatter wird zur Build-Zeit (oder zur Anfragezeit bei SSR) ausgeführt
  • Es wird kein export default benötigt – das Template IST der Export
  • <slot /> für Kind-Elemente
  • Props werden mit TypeScript-Schnittstellen typisiert

Layout-Muster

---
// src/layouts/BaseLayout.astro
import Nav from '../components/Nav.astro'
import Footer from '../components/Footer.astro'

export interface Props {
  title: string
  description?: string
  image?: string
}

const { title, description = '', image = '/og-default.png' } = Astro.props
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
---

<html lang="de">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="canonical" href={canonicalURL} />
    <title>{title}</title>
    <meta name="description" content={description} />
    
    <!-- OpenGraph -->
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:image" content={image} />
    <meta property="og:url" href={canonicalURL} />
    <meta property="og:type" content="website" />
    
    <!-- Twitter -->
    <meta name="twitter:card" content="summary_large_image" />
    
    <slot name="head" />
  </head>
  <body class="bg-white text-gray-900 antialiased">
    <Nav />
    <main>
      <slot />
    </main>
    <Footer />
  </body>
</html>

Verwendung:

---
// src/pages/index.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import Hero from '../components/sections/Hero.astro'
---

<BaseLayout title="Vera Sesiom - Softwareentwicklung" description="Wir entwickeln Software, die zählt.">
  <Hero />
</BaseLayout>

Content-Collection-Muster

Definiere strukturierte Inhalte mit Zod-Schema-Validierung.

// src/content/config.ts
import { defineCollection, z } from 'astro:content'

const blogCollection = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    description: z.string(),
    pubDate: z.coerce.date(),
    updatedDate: z.coerce.date().optional(),
    author: z.string().default('Vera Sesiom'),
    image: z.object({
      url: z.string(),
      alt: z.string()
    }).optional(),
    tags: z.array(z.string()).default([]),
    draft: z.boolean().default(false)
  })
})

export const collections = {
  blog: blogCollection
}
---
title: "Warum wir Astro für Landing Pages gewählt haben"
description: "Performance und Entwicklererfahrung sind entscheidend. Hier ist, warum Astro unsere erste Wahl für Content-Seiten ist."
pubDate: 2024-01-15
author: "Tech Lead"
image:
  url: "/images/blog/astro-landing.jpg"
  alt: "Astro-Logo auf einer modernen Website"
tags: ["astro", "performance", "architektur"]
---

Hier steht der Inhalt...
---
// src/pages/blog/index.astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'

const posts = (await getCollection('blog'))
  .filter(post => !post.data.draft)
  .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
---

<BaseLayout title="Blog" description="Neueste Artikel von Vera Sesiom">
  <h1>Blog</h1>
  <ul>
    {posts.map(post => (
      <li>
        <a href={`/blog/${post.slug}`}>
          <h2>{post.data.title}</h2>
          <p>{post.data.description}</p>
          <time>{post.data.pubDate.toLocaleDateString('de-DE')}</time>
        </a>
      </li>
    ))}
  </ul>
</BaseLayout>
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'

export async function getStaticPaths() {
  const posts = await getCollection('blog')
  return posts.map(post => ({
    params: { slug: post.slug },
    props: { post }
  }))
}

const { post } = Astro.props
const { Content } = await post.render()
---

<BaseLayout title={post.data.title} description={post.data.description}>
  <article>
    <header>
      <h1>{post.data.title}</h1>
      <time>{post.data.pubDate.toLocaleDateString('de-DE')}</time>
      <p>Von {post.data.author}</p>
    </header>
    <Content />
  </article>
</BaseLayout>

Insel-Architektur (Vue-Komponenten)

Interaktive Komponenten liefern JS nur bei Bedarf aus. Verwende Client-Direktiven, um die Hydrierung zu steuern.

<!-- src/components/interactive/ContactForm.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'

const form = ref({
  name: '',
  email: '',
  message: ''
})

const errors = ref<Record<string, string>>({})

const isValid = computed(() => {
  return form.value.name && 
         form.value.email.includes('@') && 
         form.value.message.length > 10
})

async function submitForm() {
  if (!isValid.value) return
  
  // An API senden
  const response = await fetch('/api/contact', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(form.value)
  })
  
  if (response.ok) {
    // Erfolg behandeln
    form.value = { name: '', email: '', message: '' }
  }
}
</script>

<template>
  <form @submit.prevent="submitForm" class="space-y-4">
    <div>
      <label for="name">Name</label>
      <input v-model="form.name" type="text" id="name" required />
    </div>
    <div>
      <label for="email">E-Mail</label>
      <input v-model="form.email" type="email" id="email" required />
    </div>
    <div>
      <label for="message">Nachricht</label>
      <textarea v-model="form.message" id="message" rows="4" required />
    </div>
    <button type="submit" :disabled="!isValid">
      Nachricht senden
    </button>
  </form>
</template>

Verwendung in Astro:

---
// src/pages/contact.astro
import BaseLayout from '../layouts/BaseLayout.astro'
import ContactForm from '../components/interactive/ContactForm.vue'
---

<BaseLayout title="Kontakt">
  <h1>Nimm Kontakt auf</h1>
  <p>Wir freuen uns, von dir zu hören.</p>
  
  <!-- client:visible = hydrieren, wenn in den sichtbaren Bereich gescrollt wird (BEST PRACTICE) -->
  <ContactForm client:visible />
</BaseLayout>

Entscheidungsbaum für Client-Direktiven

DirektiveWann verwendenJS wird geladen
Keine DirektiveStatische Inhalte, keine InteraktivitätKein JS
client:visibleInteraktivität unterhalb der Falte (Formulare, Karussells) – STANDARDBeim Hineinscrollen in den sichtbaren Bereich
client:loadKritische Interaktivität oberhalb der FalteSofort
client:idleNicht-kritische Verbesserungen (Analytics, Chat)Nachdem die Seite inaktiv ist
client:mediaAbhängig von Breakpoints (mobile Navigation)Wenn die Media Query zutrifft
client:onlySPA-ähnliche Komponenten (SSR überspringen)Sofort, kein SSR

Best Practices:

  • Standardmäßig keine Direktive – rein statisch
  • Standardmäßig client:visible bei Bedarf an Interaktivität
  • client:load sparsam verwenden – nur für kritische Elemente oberhalb der Falte
  • client:idle für nicht-blockierende Verbesserungen verwenden
---
<!-- Gute Beispiele -->
import Hero from './Hero.astro'                           <!-- Statisch, kein JS -->
import Carousel from './Carousel.vue'             
import MobileMenu from './MobileMenu.vue'         
import Chat from './Chat.vue'                     
import Stats from './Stats.astro'                          <!-- Statischer Zähler = CSS -->

<Hero />                                                  <!-- Statisch, keine Hydrierung -->
<Carousel client:visible />                               <!-- Hydrieren, wenn sichtbar -->
<MobileMenu client:media="(max-width: 768px)" />          <!-- Nur mobil -->
<Chat client:idle />                                       <!-- Nachdem die Seite zur Ruhe gekommen ist -->
<Stats />                                                  <!-- Reine CSS-Animationen, kein JS nötig -->

Bildoptimierung

Verwende astro:assets für automatische Optimierung.

---
// src/pages/index.astro
import { Image } from 'astro:assets'
import heroImage from '../assets/images/hero.png'
---

<Image 
  src={heroImage} 
  alt="Hero-Bild" 
  width={800} 
  height={600}
  loading="lazy"
  format="webp"
/>

Für Bilder aus CMS oder externen Quellen:

---
import { Image } from 'astro:assets'
---

<Image 
  src="https://example.com/image.jpg" 
  alt="Externes Bild" 
  width={800}
  height={600}
/>

Regeln:

  • Gib immer width und height an, um CLS zu vermeiden
  • Verwende loading="lazy" für Bilder unterhalb der Falte
  • Bevorzuge die Formate webp oder avif
  • Halte Hero-Bilder optimiert und angemessen groß

SEO-Muster

---
// src/components/SEO.astro
export interface Props {
  title: string
  description: string
  image?: string
  article?: boolean
  publishedTime?: Date
  author?: string
  tags?: string[]
}

const { 
  title, 
  description, 
  image = '/og-default.png',
  article = false,
  publishedTime,
  author,
  tags = []
} = Astro.props

const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const imageURL = new URL(image, Astro.site)
---

<!-- Primäre Meta-Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />

<!-- Open Graph -->
<meta property="og:type" content={article ? 'article' : 'website'} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imageURL} />

<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={imageURL} />

{article && publishedTime && (
  <>
    <meta property="article:published_time" content={publishedTime.toISOString()} />
    {author && <meta property="article:author" content={author} />}
    {tags.map(tag => <meta property="article:tag" content={tag} />)}
  </>
)}

<!-- Schema.org -->
<script type="application/ld+json" set:html={JSON.stringify({
  '@context': 'https://schema.org',
  '@type': article ? 'Article' : 'WebPage',
  name: title,
  description: description,
  url: canonicalURL,
  image: imageURL
})} />

Verwandte Skills