Skip to content

Production

The search, sharing and security defaults prfct ships with: what each one does, and how to keep them as you build on top.

An interface people can't find, or can't trust, isn't finished. This site ships with the defaults below, and so does any app you start from its repository. None of them needs a database or a third-party service: each one lives in a file you can read, change or copy. In an existing project, the security baseline installs from the registry (see In another project below); the metadata helpers are short files to copy from the repository.

Search engines

Metadata

Every page describes itself: a title, a description, its canonical URL, its translations (hreflang, with x-default), and the Open Graph and Twitter cards that link previews read. pageMetadata() writes all of it from four values.

app/[locale]/pricing/page.tsx
import type { Metadata } from "next"

import { pageMetadata } from "@/lib/i18n/metadata"
import { getI18n } from "@/lib/i18n/server"

export async function generateMetadata(): Promise<Metadata> {
  const { locale, t } = await getI18n()
  return pageMetadata({
    path: "/pricing",
    locale,
    title: t.pricing.title,
    description: t.pricing.description,
  })
}

Sitemap and robots

app/sitemap.ts lists every page in every language, each with links to its translations. app/robots.ts points crawlers to the sitemap and keeps data endpoints, such as the search index, out of results. Documentation pages and example apps are listed automatically. When you add a route of your own, add its path to the list in that file.

Social cards

Each page has its own 1200 × 630 card, rendered at build time from its title and description by lib/og.tsx. There is no image service to run and nothing to design by hand.

pageMetadata() points each page at /og followed by its own path, and the cards for the documentation, the examples and the theme builder already exist. A route you add needs a card route of its own: copy app/[locale]/og/themes/route.tsx to the matching folder, such as app/[locale]/og/pricing/route.tsx. Without it, the page's link preview has no image.

Structured data

<JsonLd> writes schema.org data that search engines use for rich results. The home page describes the site and its source code; documentation pages describe themselves as a TechArticle with a BreadcrumbList. The component escapes <, so no string in the data can close its script element.

Security headers

Every response carries a security baseline, set once in lib/security.ts and applied in next.config.ts.

HeaderWhat it prevents
Content-Security-PolicyScripts, styles, frames and requests from origins you haven't approved; plugins; <base> hijacking; forms that post to other sites.
Strict-Transport-SecurityDowngrades to plain HTTP, for two years, on every subdomain.
X-Content-Type-OptionsBrowsers guessing a file's type and running it as a script.
X-Frame-OptionsClickjacking: other sites showing yours in a frame.
Referrer-PolicyFull URLs, with their query strings, leaking to other sites.
Cross-Origin-Opener-PolicyPages you open reaching back through window.opener.
Permissions-PolicyCamera, microphone, location, payment and USB APIs that nothing on the page uses, and the Topics API that ad networks use to profile visitors.

In another project

The baseline is a registry item. Install it into any Next.js project, then apply it in next.config.ts:

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/security.json
next.config.ts
import type { NextConfig } from "next"

import { securityHeaders } from "./lib/security"

const nextConfig: NextConfig = {
  poweredByHeader: false,
  async headers() {
    return [{ source: "/:path*", headers: securityHeaders }]
  },
}

export default nextConfig

Check Strict-Transport-Security before you deploy: it covers every subdomain for two years and carries preload, so every subdomain must already serve HTTPS.

Allowing a service

The policy is data: one entry per directive. To use a service from another origin, add that origin to the directives it needs. An analytics script, for example, loads a script and sends requests:

lib/security.ts
export const contentSecurityPolicy: Record<string, string[]> = {
  // …
  "script-src": [
    "'self'",
    "'unsafe-inline'",
    ...(isDev ? ["'unsafe-eval'"] : []),
    "https://plausible.io",
  ],
  // …
  "connect-src": ["'self'", ...(isDev ? ["ws:"] : []), "https://plausible.io"],
}

Keep the isDev entries: they apply only in next dev, where React needs eval to rebuild error stacks and hot reload needs its socket.

When something doesn't load, open the browser console: every blocked request is reported there, with the directive that blocked it.

Stricter scripts

Static pages are built once, so they can't carry a fresh nonce for every request. The policy therefore allows inline scripts, which Next.js uses to start the page; scripts from other origins stay blocked. If your app renders every request anyway, behind a sign-in for example, switch to a nonce-based policy in proxy.ts, as the Next.js guide describes.

Reporting vulnerabilities

/.well-known/security.txt (RFC 9116) tells security researchers where to report a problem. Its expiry date renews with every deploy. Point its Contact at your own address in app/.well-known/security.txt/route.ts.

Before you ship

  • Set NEXT_PUBLIC_SITE_URL to your domain, so canonical URLs, the sitemap, social cards and install commands point to it.
  • Give every new route a generateMetadata that calls pageMetadata(), a card route under app/[locale]/og/, and an entry in app/sitemap.ts.
  • Keep secrets in environment variables without the NEXT_PUBLIC_ prefix, and read them only in server code. Anything with the prefix is shipped to every visitor.
  • Validate every input on the server, even when the form already validated it in the browser.
  • Render what people type as text. dangerouslySetInnerHTML is for markup your own code produced, never for user input.
  • Check the deployed site: securityheaders.com grades the headers, and Google's Rich Results Test reads the structured data.