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.
| Function | Schema type | Arguments |
|---|---|---|
generateWebSiteSchema | WebSite | name, url |
generateBreadcrumbSchema | BreadcrumbList | items: { name; url }[] |
generateOrganizationSchema | Organization | name, 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 allpublishedposts, parses each row withblogPostSchema.parse(...), and lists them.app/blog/[slug]/page.tsx, fetches a single post byslug, validates it, and renders the<SEO>component with a per-post canonical URL and aBlogPostingJSON-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.
| Export | From | What it does |
|---|---|---|
getOptimizedImageUrl | libs/imageOptimizer.ts | Builds a /_next/image URL with width, quality and format params (defaults to webp, quality 75). |
generateSrcSet | libs/imageOptimizer.ts | Produces a responsive srcset across DEFAULT_BREAKPOINTS (640 to 1536). |
generateBlurPlaceholder | libs/imageOptimizer.ts | Returns a base64 SVG placeholder for low-CLS loading. |
useResponsiveImage | libs/useResponsiveImage.ts | Hook 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.tsxreadsprocess.env.NEXTAUTH_URL(with a localhost fallback) for both the canonical link and theWebSiteschema URL.app/blog/[slug]/page.tsxusesenv.NEXTAUTH_URLfromlibs/config.tsto build per-post canonical URLs.app/sitemap.xml.tsuses the sameenv.NEXTAUTH_URLas 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.
