Documentation

Search Engine Optimization

Learn how to optimize your Dirstarter website for search engines and social media sharing.

Dirstarter comes with built-in SEO features to help your directory website rank better in search engines and look great when shared on social media.

Metadata Configuration

The metadata configuration in Dirstarter is centralized and type-safe using Next.js's Metadata API. The base configuration is located in config/metadata.ts:

config/metadata.ts
import type { Metadata } from "next"
import { linksConfig } from "~/config/links"
import { siteConfig } from "~/config/site"

export const metadataConfig: Metadata = {
  openGraph: {
    url: "/",
    siteName: siteConfig.name,
    locale: "en_US",
    type: "website",
  },
  twitter: {
    site: "@dirstarter",
    creator: "@piotrkulpinski",
    card: "summary_large_image",
  },
  alternates: {
    canonical: "/",
    types: { "application/rss+xml": linksConfig.feeds },
  },
}

Pages build their metadata with getPageData and getPageMetadata from lib/pages.ts. getPageMetadata merges the base config, sets the canonical URL and falls back to the site's home card when a page has no Open Graph image of its own.

Static Pages

A static page reads its title and description from the translations and passes its own card:

app/(web)/about/page.tsx
const getData = cache(async () => {
  const t = await getTranslations("pages.about")
  const url = "/about"
  const title = t("title")
  const description = t("description", { siteName: siteConfig.name })

  return getPageData(url, title, description, {
    metadata: { openGraph: { images: await getPageOpenGraphImage("about") } },
    breadcrumbs: [{ url, title }],
  })
})

export async function generateMetadata() {
  return getPageMetadata(await getData())
}

Dynamic Pages

Tool, category and blog post pages pass the card of the entity they show:

app/(web)/[slug]/page.tsx
export async function generateMetadata(props: Props): Promise<Metadata> {
  const { tool, url, metadata } = await getData(props)

  const robots = !isToolPublished(tool) ? { index: false, follow: false } : undefined
  const openGraph = { images: getToolOpenGraphImage(tool) }

  return getPageMetadata({ url, metadata: { ...metadata, openGraph, robots } })
}

OpenGraph Images

Dirstarter draws every Open Graph card with ogier, a card renderer built on satori. One route renders all of them: app/api/og/[type]/[slug]/route.ts. The type is page, tool, category or post, and the route loads the entity by its slug. Cards are never built from query parameters, so nobody can make your site render arbitrary text.

CardShows
pageThe page title and description from the translations. Pages without a card of their own share the home card
toolThe favicon, name and tagline (or the description when there is no tagline)
categoryThe category title, description and tool count
postThe post title, author and publish date

Versioned URLs

Each card URL ends with a v query, a short hash of the data the card shows. The helpers in lib/opengraph.ts build these URLs:

lib/opengraph.ts
getPageOpenGraphImage("about") // /api/og/page/about?v=...
getToolOpenGraphImage(tool) // /api/og/tool/<slug>?v=...
getCategoryOpenGraphImage(category) // /api/og/category/<slug>?v=...
getPostOpenGraphImage(post) // /api/og/post/<slug>?v=...

The version changes only when something on the card changes. So the route caches each image for a year, and an edit still produces a fresh image. A request with a stale or missing version, or any extra query parameter, is redirected to the current URL, so cache-busting parameters never render a second copy.

Design

lib/opengraph-cards.tsx holds the renderer and one loader per card type. Titles use Inter Display and the rest of the text uses Inter, loaded from local font files in lib/image-fonts.ts.

The card colors live in config/images.ts, next to the badge colors. satori cannot read the OKLCH tokens in app/styles.css, so the config keeps hex copies of them. Update both together when you change your brand colors.

After changing how the cards look, bump imagesConfig.openGraph.revision. The revision is part of every card version, so social networks and your CDN fetch the new design instead of the cached one.

Sitemap Generation

Dirstarter uses a split sitemap architecture with a sitemap index and individual sitemaps for each content type. This keeps each file small and cacheable while scaling to thousands of entries.

Sitemap Index

The index at app/sitemap.xml/route.ts lists all individual sitemaps:

app/sitemap.xml/route.ts
export async function GET() {
  // Generates a sitemap index XML pointing to individual sitemaps
  // e.g., /sitemap/pages, /sitemap/tools, etc.
  // ...
}

Individual Sitemaps

Each content type has its own sitemap generated by app/sitemap/[id]/route.ts. The available sitemaps are defined as a constant, and the route renders each at request time, returning 404 for unknown ids:

app/sitemap/[id]/route.ts
const sitemaps = ["pages", "tools", "categories", "tags", "posts"]

export async function GET(_: Request, { params }: RouteContext<"/sitemap/[id]">) {
  await connection() // request-time only, so the cached queries are skipped at build

  const { id } = await params
  if (!sitemaps.includes(id)) {
    // 404 + Cache-Control: no-store
  }

  // Switches on `id` to query the appropriate content type
  // and generates a sitemap XML with loc + lastmod entries
  // ...
}

Robots.txt

The robots.txt file is automatically generated and configured to allow search engines to crawl your site while protecting sensitive routes:

app/robots.ts
import type { MetadataRoute } from "next"
import { siteConfig } from "~/config/site"

export default function robots(): MetadataRoute.Robots {
  const baseUrl = siteConfig.url

  return {
    rules: [
      {
        userAgent: "*",
        allow: "/",
        disallow: ["/app*", "/auth*"],
      },
      {
        userAgent: "Googlebot",
        disallow: "/*/opengraph-image-",
      },
    ],
    sitemap: `${baseUrl}/sitemap.xml`,
  }
}

Additional SEO Features

  1. Canonical URLs: Every page has a canonical URL to prevent duplicate content issues.
  2. RSS Feed: Built-in RSS feed support for blog posts and content updates.
  3. Structured Data: Automatically generated JSON-LD for tools and blog posts.
  4. Mobile Optimization: All pages are responsive and mobile-friendly by default.
  5. Performance: Built-in image optimization and lazy loading for better Core Web Vitals.

Last updated on

On this page

Join hundreds of directory builders

Build your directory, launch, earn

Don't waste time on Stripe subscriptions or designing a pricing section. Get started today with our battle-tested stack and built-in monetization features.

Get Lifetime Access
30-day money-back guarantee