SEO groundwork; hide the empty 'Wie wir arbeiten' block

- robots.txt and sitemap.xml (both answered with the homepage before)
- real 404 page; nginx returns 404 instead of the homepage for unknown paths
- gzip and caching for fonts, images, videos
- JSON-LD as one graph: ProfessionalService (+founders, offer), WebSite,
  FAQPage built from the FAQ answers
- sharper title and a meta description under 160 characters,
  real descriptions for imprint and privacy pages
- imprint cites § 5 DDG (TMG was replaced in May 2024)
- empty kontakt.howWeWork hides the block instead of a red placeholder

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lara 2026-10-21 11:13:06 +02:00
parent d035bbcd61
commit 275e479aba
10 changed files with 109 additions and 18 deletions

View file

@ -240,10 +240,10 @@ module.exports = [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'lead', label: 'Einleitungstext', type: 'richtext' },
{ key: 'phone', label: 'Telefonnummer (leer = Platzhalter)', type: 'text' },
{ key: 'phone', label: 'Telefonnummer (leer = ausgeblendet, auch im Impressum)', type: 'text' },
{ key: 'hours', label: 'Telefonische Erreichbarkeit unter der Nummer (leer = ausgeblendet)', type: 'text' },
{ key: 'email', label: 'E-Mail-Adresse', type: 'email' },
{ key: 'howWeWork', label: '"Wie wir arbeiten" (leer = Platzhalter)', type: 'textarea' },
{ key: 'howWeWork', label: '"Wie wir arbeiten" (leer = ausgeblendet)', type: 'textarea' },
],
},
{

View file

@ -1,6 +1,6 @@
{
"meta": {
"description": "Larpaso aus Hannover baut Websites für Inhaberbetriebe, Praxen und Handwerk: Angebot, Preise, Zeiten und Anfahrt auf einen Blick, gut bedienbar auf dem Handy, zum Festpreis."
"description": "Webdesign aus Hannover: Larpaso baut Websites für Inhaberbetriebe, Praxen und Handwerk in ganz Deutschland. Individuell, handyfreundlich, zum Festpreis."
},
"nav": {
"logo": "/logo.svg",

View file

@ -8,14 +8,31 @@ server {
# (so steht es auch in der Datenschutzerklaerung). Fehler weiter melden.
access_log off;
# Saubere URLs + Fallback
# Komprimieren: HTML/CSS/JS/SVG kommen deutlich kleiner beim Besucher an
gzip on;
gzip_comp_level 6;
gzip_min_length 512;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css text/xml application/xml application/javascript
application/json application/ld+json image/svg+xml;
# Saubere URLs. Unbekannte Adressen bekommen eine echte 404 statt der
# Startseite (sonst wertet Google sie als "Soft 404" / doppelte Inhalte).
location / {
try_files $uri $uri/ $uri.html /index.html;
try_files $uri $uri/ $uri.html =404;
}
error_page 404 /404.html;
# Astro legt gehashte Assets unter /_astro ab -> lange cachen
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Schriften, Bilder, Videos: aendern sich selten
location ~* \.(woff2|webp|jpg|jpeg|png|svg|ico|mp4)$ {
expires 7d;
add_header Cache-Control "public";
}
}

4
public/robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://larpaso.de/sitemap.xml

View file

@ -7,15 +7,21 @@ import { site, theme, themeCss, fontFacesCss } from '../lib/content';
interface Props {
title: string;
description?: string;
/** Zusätzliche schema.org-Objekte nur für diese Seite (z. B. FAQPage). */
schema?: Record<string, unknown>[];
/** Seite nicht in Suchmaschinen aufnehmen (404). */
noindex?: boolean;
}
const { title, description = site.meta.description } = Astro.props;
const { title, description = site.meta.description, schema = [], noindex = false } = Astro.props;
const canonical = new URL(Astro.url.pathname, Astro.site).href;
const ogImage = new URL('/og-larpaso.jpg', Astro.site).href;
const orgId = new URL('/#larpaso', Astro.site).href;
const orgData = {
'@context': 'https://schema.org',
'@type': 'ProfessionalService',
'@id': orgId,
name: 'Larpaso',
alternateName: 'larpaso Webdesign',
description: site.meta.description,
url: Astro.site?.href,
logo: new URL('/icon-512.png', Astro.site).href,
@ -25,7 +31,28 @@ const orgData = {
address: { '@type': 'PostalAddress', addressLocality: 'Hannover', addressRegion: 'Niedersachsen', addressCountry: 'DE' },
areaServed: { '@type': 'Country', name: 'Deutschland' },
priceRange: 'ab 500 €',
founder: [
{ '@type': 'Person', name: `Lara ${site.footer.laraNachname}` },
{ '@type': 'Person', name: `Pascal ${site.footer.pascalNachname}` },
],
knowsAbout: ['Webdesign', 'Websites für kleine Betriebe', 'Responsive Webdesign', 'Suchmaschinenoptimierung', 'Barrierefreiheit'],
makesOffer: {
'@type': 'Offer',
name: 'Website zum Festpreis',
price: '500',
priceCurrency: 'EUR',
description: 'Individuelle Website mit Texten, Gestaltung und Veröffentlichung, einmaliger Festpreis.',
},
};
const siteData = {
'@type': 'WebSite',
'@id': new URL('/#website', Astro.site).href,
name: 'Larpaso',
url: Astro.site?.href,
inLanguage: 'de-DE',
publisher: { '@id': orgId },
};
const ldJson = { '@context': 'https://schema.org', '@graph': [orgData, siteData, ...schema] };
---
<!doctype html>
@ -41,6 +68,7 @@ const orgData = {
<meta name="theme-color" content="#FCFAF6" />
<meta name="description" content={description} />
<title>{title} · Larpaso</title>
{noindex && <meta name="robots" content="noindex" />}
<link rel="canonical" href={canonical} />
<!-- Vorschau beim Teilen (WhatsApp, Signal, Facebook, LinkedIn, Mail) -->
@ -60,7 +88,7 @@ const orgData = {
<style set:html={themeCss(theme)} />
<!-- Für Suchmaschinen: wer, was, Sitz Hannover (Kunden überall). Adresse bewusst ohne Straße. -->
<script type="application/ld+json" set:html={JSON.stringify(orgData)} />
<script type="application/ld+json" set:html={JSON.stringify(ldJson)} />
</head>
<body>
<a href="#main-content" class="skip-link">Zum Inhalt springen</a>

14
src/pages/404.astro Normal file
View file

@ -0,0 +1,14 @@
---
import Layout from '../layouts/Layout.astro';
---
<Layout title="Seite nicht gefunden" description="Diese Seite gibt es bei Larpaso nicht (mehr)." noindex>
<section class="section-tight">
<div class="container legal">
<p class="eyebrow">Fehler 404</p>
<h1>Diese Seite gibt es nicht.</h1>
<p>Vielleicht hat sich die Adresse geändert. Auf der Startseite finden Sie alles über uns, unsere Arbeiten und wie Sie uns erreichen.</p>
<p><a class="btn btn-primary" href="/">Zur Startseite</a></p>
</div>
</section>
</Layout>

View file

@ -7,7 +7,7 @@ const introFallback =
'[Diese Seite ist noch ein Platzhalter. Vor dem Livegang durch eine vollständige, rechtlich geprüfte Datenschutzerklärung ersetzen — u. a. mit Angaben zu: verantwortlicher Stelle, Kontaktformular/Mailto-Versand, ggf. eingesetzten Diensten (Hosting, Schriftarten, Analyse-Tools), Speicherdauer und Betroffenenrechten.]';
---
<Layout title="Datenschutz" description="Datenschutzerklärung von larpaso.">
<Layout title="Datenschutz" description="Datenschutzerklärung von Larpaso: welche Daten die Website verarbeitet, wo sie gehostet wird und welche Rechte Sie haben.">
<section class="section-tight">
<div class="container legal">
<p class="eyebrow">Datenschutz</p>

View file

@ -5,11 +5,11 @@ import { site, richWithEmail } from '../lib/content';
const { impressum, kontakt } = site;
---
<Layout title="Impressum" description="Impressum von larpaso.">
<Layout title="Impressum" description="Impressum von Larpaso, Webdesign aus Hannover: Anbieter, Kontakt und rechtliche Angaben.">
<section class="section-tight">
<div class="container legal">
<p class="eyebrow">Impressum</p>
<h1>Angaben gemäß § 5 TMG</h1>
<h1>Angaben gemäß § 5 DDG</h1>
<p class={impressum.anbieterAngaben ? undefined : 'placeholder'} style="white-space: pre-line">
{impressum.anbieterAngaben ||

View file

@ -16,9 +16,22 @@ const zoomFor = (item?: MappeItem) =>
: undefined;
const findItem = (src: string) =>
mappe.items.find((i) => src && (i.image === src || i.mobileImage === src));
// FAQ für Google als strukturierte Daten (nur beantwortete Fragen)
const plain = (html: string) => html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
const faqSchema = {
'@type': 'FAQPage',
mainEntity: faq.items
.filter((i) => i.question && i.answer)
.map((i) => ({
'@type': 'Question',
name: plain(i.question),
acceptedAnswer: { '@type': 'Answer', text: plain(i.answer) },
})),
};
---
<Layout title="Webdesign aus Hannover">
<Layout title="Webdesign aus Hannover · Websites zum Festpreis" schema={[faqSchema]}>
<!-- Hero -->
<section class="section hero">
<div class="container hero-grid">
@ -291,12 +304,12 @@ const findItem = (src: string) =>
<p class="contact-hint">Jederzeit per E-Mail erreichbar, Antwort in der Regel am selben Tag.</p>
</div>
{kontakt.howWeWork && (
<div class="contact-detail">
<p class="contact-label">Wie wir arbeiten</p>
<p class={kontakt.howWeWork ? undefined : 'placeholder'}>
{kontakt.howWeWork || '[Wie wir arbeiten — noch zu ergänzen]'}
</p>
<p>{kontakt.howWeWork}</p>
</div>
)}
</div>
<form class="contact-form card" aria-label="Kontaktformular" id="contact-form">

15
src/pages/sitemap.xml.ts Normal file
View file

@ -0,0 +1,15 @@
import type { APIRoute } from 'astro';
// Nur echte Seiten. /leistungen, /ueber-uns, /kontakt sind Weiterleitungen und gehören nicht hinein.
const seiten = ['/', '/impressum/', '/datenschutz/'];
export const GET: APIRoute = ({ site }) => {
const heute = new Date().toISOString().slice(0, 10);
const urls = seiten
.map((p) => ` <url><loc>${new URL(p, site).href}</loc><lastmod>${heute}</lastmod></url>`)
.join('\n');
return new Response(
`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`,
{ headers: { 'Content-Type': 'application/xml; charset=utf-8' } },
);
};