ShipVeryFastShipVeryFast
Documentation

SEO & metadata

ShipVeryFast ships small, explicit SEO primitives instead of a magic plugin: JSON-LD schema helpers in libs/structuredData.ts, a client <SEO> component, Next.js route metadata, a dynamic sitemap, and image-optimization utilities. This page covers where each piece lives and how to wire it into your own pages.

JSON-LD helpers in libs/structuredData.ts

libs/structuredData.ts exports three pure functions that return plain objects shaped as schema.org JSON-LD. They take no global state, so they are easy to call from any server or client component.

FunctionSchema typeArguments
generateWebSiteSchemaWebSitename, url
generateBreadcrumbSchemaBreadcrumbListitems: { name; url }[]
generateOrganizationSchemaOrganizationname, url, logoUrl

Each returns a JSON-LD object with the right @context and @type. generateBreadcrumbSchema maps your array into positioned ListItem entries automatically.

import {
  generateOrganizationSchema,
  generateBreadcrumbSchema,
} from "@/libs/structuredData";

// Organization schema for your brand
const org = generateOrganizationSchema(
  "ShipVeryFast",
  "https://yourdomain.com",
  "https://yourdomain.com/logo.png",
);

// Breadcrumb trail for a nested page
const breadcrumbs = generateBreadcrumbSchema([
  { name: "Home", url: "https://yourdomain.com" },
  { name: "Blog", url: "https://yourdomain.com/blog" },
  { name: "My Post", url: "https://yourdomain.com/blog/my-post" },
]);

Injecting JSON-LD into a page

The schema objects are rendered by components/ui/seo.tsx (the default-exported SEO component). Pass an array to its structuredData prop and it emits one <script type="application/ld+json"> tag per entry inside next/head, alongside the title, description, canonical link, and Open Graph tags.

The home page wires this up in app/page.tsx using the WebSite schema:

import Seo from "@/components/ui/seo";
import { generateWebSiteSchema } from "@/libs/structuredData";

export default function Home() {
  const siteUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
  const structuredData = [generateWebSiteSchema("ShipVeryFast", siteUrl)];

  return (
    <Seo
      title="ShipVeryFast | Launch your SaaS in days, not months"
      description="Production-ready Next.js boilerplate with auth, Stripe payments, email, database and security."
      canonicalUrl={siteUrl}
      structuredData={structuredData}
    />
  );
}

components/ui/seo.tsx is a client component (it uses next/head). For App Router server components you can also use Next.js' native metadata export, covered next, and reserve <SEO> for pages that already render on the client or need inline JSON-LD.

Next.js metadata and per-route titles/descriptions

The root metadata export lives in app/layout.tsx and provides the default title and description for the whole app:

// app/layout.tsx
export const metadata = {
  title: "ShipVeryFast, Launch your SaaS in days, not months",
  description:
    "The production-ready Next.js SaaS boilerplate with auth, Stripe payments, email, a database schema, security and tests.",
};

Individual routes override these by exporting their own metadata object. The blog index does exactly this in app/blog/page.tsx:

// app/blog/page.tsx
export const metadata = {
  title: "Blog - ShipVeryFast",
  description: "Latest news and updates from ShipVeryFast",
};

Use the static metadataexport for static routes, and Next.js' generateMetadata function when the title or description depends on dynamic data such as a slug.

The blog under app/blog and the blogPost Zod model

The blog is a real, working example of database-backed SEO. Posts live in a Supabase blog_posts table and are validated against the blogPostSchema Zod model in models/blogPost.ts (also re-exported from models/index.ts).

// models/blogPost.ts
export const blogPostSchema = z.object({
  id: z.string(),
  title: z.string().min(1),
  slug: z.string().min(1),
  content: z.string().min(1),
  excerpt: z.string().min(1),
  author_id: z.string(),
  published: z.boolean(),
  tags: z.array(z.string()),
  meta_title: z.string().optional(),
  meta_description: z.string().optional(),
  created_at: z.string().datetime(),
  updated_at: z.string().datetime().nullable(),
});

export type BlogPost = z.infer<typeof blogPostSchema>;

Note the dedicated meta_title and meta_description fields for overriding search-result copy per post, plus tags for categorization.

  • app/blog/page.tsx, server component that fetches all published posts, parses each row with blogPostSchema.parse(...), and lists them.
  • app/blog/[slug]/page.tsx, fetches a single post by slug, validates it, and renders the <SEO> component with a per-post canonical URL and a BlogPosting JSON-LD object.

The detail page builds its canonical URL and structured data straight from the post:

// app/blog/[slug]/page.tsx (abridged)
const canonicalUrl = `${env.NEXTAUTH_URL}/blog/${post.slug}`;

<SEO
  title={`${post.title} | ShipVeryFast Blog`}
  description={excerpt}
  canonicalUrl={canonicalUrl}
  openGraph={{ title: post.title, description: excerpt, url: canonicalUrl }}
  structuredData={[
    {
      "@context": "https://schema.org",
      "@type": "BlogPosting",
      headline: post.title,
      datePublished: post.created_at,
      author: { "@type": "Person", name: "Author Name" },
      mainEntityOfPage: { "@type": "WebPage", "@id": canonicalUrl },
    },
  ]}
/>

Image optimization helpers

Two libraries cover image SEO and performance. libs/imageOptimizer.ts provides URL and srcset helpers, and libs/useResponsiveImage.ts exposes a React hook for size-aware loading.

ExportFromWhat it does
getOptimizedImageUrllibs/imageOptimizer.tsBuilds a /_next/image URL with width, quality and format params (defaults to webp, quality 75).
generateSrcSetlibs/imageOptimizer.tsProduces a responsive srcset across DEFAULT_BREAKPOINTS (640 to 1536).
generateBlurPlaceholderlibs/imageOptimizer.tsReturns a base64 SVG placeholder for low-CLS loading.
useResponsiveImagelibs/useResponsiveImage.tsHook returning getSizes, getQuality and getDimensions based on the current breakpoint.
import { useResponsiveImage } from "@/libs/useResponsiveImage";

function Hero() {
  const { getSizes, getQuality } = useResponsiveImage();
  // getSizes() -> a sizes string, getQuality() -> 70..85 by screen size
}

Note that getOptimizedImageUrl skips SVGs and, by default, external URLs, set NEXT_PUBLIC_OPTIMIZE_EXTERNAL_IMAGES=true to opt remote images in, and NEXT_PUBLIC_URL to control the base host used in generated URLs. Neither is required by the env schema, so they only matter if you use these helpers.

Canonical URLs and the site URL

Canonical URLs across the app are derived from NEXTAUTH_URL. This variable is validated in libs/config.ts as z.string().url(), so it must be a valid URL or the app throws at startup. It defaults to http://localhost:3000 in .env.example and should be set to your deployed origin in production.

  • app/page.tsx reads process.env.NEXTAUTH_URL (with a localhost fallback) for both the canonical link and the WebSite schema URL.
  • app/blog/[slug]/page.tsx uses env.NEXTAUTH_URL from libs/config.ts to build per-post canonical URLs.
  • app/sitemap.xml.ts uses the same env.NEXTAUTH_URL as its base.

Because everything keys off one variable, pointing your structured data, canonicals, and sitemap at production is a single env change.

Sitemap, robots, and Open Graph considerations

Sitemap

app/sitemap.xml.ts is a Next.js route handler that serves /sitemap.xml. It builds the XML from a hard-coded routes array and your NEXTAUTH_URL base, extend that array as you add pages (e.g. iterate your published blog slugs):

// app/sitemap.xml.ts (abridged)
export async function GET() {
  const baseUrl = env.NEXTAUTH_URL;
  const routes = ["", "features", "pricing", "social-proof", "faq"];
  // ...builds <urlset> with <loc> + <lastmod> per route
}

Robots

The boilerplate does not ship a robots.txt or app/robots.ts out of the box. To control crawling, add an app/robots.ts route (or a static public/robots.txt) that points at ${NEXTAUTH_URL}/sitemap.xml.

Open Graph

Open Graph tags are emitted by the <SEO> component via its openGraph prop (title, description, url, image), the blog detail page sets all of these. There is no auto-generated OG image, so supply your own image URL, or add a Next.js opengraph-image file if you want generated cards.

Next steps