SEO with Yugen UI
Yugen components render plain HTML on the server, so search engines can read the content. What is left is per-page metadata, heading structure, real links and alt text for images.
What is in the HTML
Client components still render on the server; what crawlers miss is content that only appears after an interaction.
In the Next.js App Router, components marked "use client" still render to HTML on the server before they hydrate. Text in a Card, Table or Accordion is already in the HTML that is sent. The difference is content that only exists once someone interacts:
| Component | In the initial HTML |
|---|---|
| Accordion | Yes — closed panels are rendered (hidden), so crawlers read them. |
| Tabs | Only the selected tab. Put content that matters for search in the default tab, or give it its own page. |
| Dialog, Drawer, Popover, Menu, Tooltip | No — they render when opened, through a portal. Keep the main content out of them. |
| Carousel | Yes — every slide is in the HTML. |
| DataGrid, Table | Yes, for data available when the server renders (not data fetched later on the client). |
Setting up Next.js with Yugen: Server-side rendering & Next.js.
Title and description
One title and one description per page, written for people reading the search results.
// app/pricing/page.tsx — Server Component
import type { Metadata } from "next";
import { Button, Card } from "yugen-ui";
export const metadata: Metadata = {
title: "Pricing · Acme",
description: "Three plans for teams of 1 to 500 people, with a 14-day trial.",
alternates: { canonical: "https://acme.vn/pricing" },
openGraph: { title: "Pricing · Acme", images: ["/og/pricing.png"] },
};
export default function PricingPage() {
return (
<main>
<h1 className="text-title1 font-semibold">Pricing</h1>
{/* Card, Button… are client components but still render to HTML on the server */}
</main>
);
}Pages with dynamic content (posts, products) use generateMetadata:
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const post = await getPost((await params).slug);
return { title: `${post.title} · Acme`, description: post.excerpt };
}A Vite SPA has no per-page metadata on the server. Pages that should appear in search results need prerendering at build time or a framework with SSR; the Blog template of create-yugen-ui writes the title, description and Open Graph tags of each post at build time.
Heading structure
One h1 per page, and no skipped levels below it.
Size and heading level are separate choices: pick the element for the structure (h1–h3) and the size from the type ramp (text-title1, text-subtitle1). Some components render a heading element themselves:
| Component | Element |
|---|---|
| DialogTitle | h2 |
| DrawerHeaderTitle | h2 |
| EmptyTitle | h3 |
| Card, CardHeader | No heading element of their own — pass the h2 / h3 that fits where the card sits. |
Links crawlers can follow
Navigate with an a element that has an href; never change pages from a button's onClick.
import NextLink from "next/link";
import { Button, Link } from "yugen-ui";
// Yugen's Link is an <a href> — crawlers follow it.
<Link href="/docs">Docs</Link>
// Use the framework's router and keep Yugen's look: asChild.
<Link asChild>
<NextLink href="/pricing">Pricing</NextLink>
</Link>
// Looks like a Button, navigates like a link.
<Button appearance="primary" asChild>
<NextLink href="/signup">Sign up</NextLink>
</Button>asChild keeps Yugen's classes and focus states but lets the framework render the a (prefetching, client-side navigation). Crawlers only follow href, so <Button onClick={() => router.push(...)}> leads to a page nobody finds.
Images and avatars
alt describes what the image shows; decorative images get an empty alt.
import { Avatar, Image } from "yugen-ui";
<Image src="/team.jpg" alt="The Acme product team at the Da Nang office" width={640} height={360} />
<Image src="/wave.svg" alt="" /> {/* decorative image: empty alt */}
<Avatar name="Nguyen Minh Anh" src="/u/anh.jpg" /> {/* role="img", named by name */}Set width and height (or wrap the image in AspectRatio) so the browser reserves the space before the image loads, which avoids layout shift (CLS). Icon-only buttons need an aria-label; it is also the only text crawlers get from them.
Before you ship
- Every page has its own title and description, and a canonical URL when the same content lives at several URLs.
- The main content is in the initial HTML, not behind a non-default tab, a Dialog or a Popover.
- One h1, heading levels in order, and an alt on every image.
- Navigation uses a href (Link, or asChild with the framework's link).
- Changing pages from a Button's onClick.
- Picking the heading element for the size you want.
- An empty alt on an image that carries content, or keywords stuffed into alt.