Customization & Optimization
Customize metadata and optimize SEO in Plainform for your specific needs
This page shows how Plainform's SEO implementation works today and how to extend the same patterns for your own routes.
Dynamic Metadata
Blog Posts
Plainform already generates blog post metadata from MDX frontmatter in app/(base)/blog/[...slug]/page.tsx. Use this as the reference pattern when you add other dynamic content types, such as product pages, changelog entries, or customer stories.
import { blogSource } from '@/lib/source';
export async function generateMetadata({ params }: { params: { slug: string[] } }) {
const page = blogSource.getPage(params.slug);
if (!page) {
return { title: 'Post Not Found' };
}
return {
title: page.data.title,
description: page.data.summary,
alternates: {
canonical: page.url,
},
openGraph: {
title: page.data.title,
description: page.data.description,
url: env.NEXT_PUBLIC_SITE_URL + page.url,
type: 'article',
images: [page.data.img],
},
};
}Product Pages
Generate metadata from database:
import { prisma } from '@/lib/prisma/prisma';
import { siteConfig } from '@/config/siteConfig';
export async function generateMetadata({ params }: { params: { id: string } }) {
const product = await prisma.product.findUnique({
where: { id: params.id },
});
if (!product) {
return { title: 'Product Not Found' };
}
return {
title: `${product.name} | ${siteConfig.siteName}`,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [product.imageUrl],
},
};
}Dynamic Sitemap
Add dynamic routes to your sitemap:
import { getServerSideSitemap } from 'next-sitemap';
import { blogSource } from '@/lib/source';
import { env } from '@/env';
export async function GET() {
const posts = blogSource.getPages();
const fields = posts.map((post) => ({
loc: `${env.NEXT_PUBLIC_SITE_URL}/blog/${post.slugs.join('/')}`,
lastmod: new Date(post.data.date).toISOString(),
changefreq: 'weekly',
priority: 0.7,
}));
return getServerSideSitemap(fields);
}Add to next-sitemap.config.js:
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
generateRobotsTxt: true,
exclude: [
'/icon*.png',
'/icon*.svg',
'/apple-icon*',
'/favicon.ico',
'/opengraph-image*',
'*.json',
'/order',
'/order*',
'/sign-in',
'/sign-in*',
'/sign-up',
'/sign-up*',
'/forgot-password',
'/forgot-password*',
'/sso-callback',
'/sso-callback*',
'/legal/privacy-policy',
'/legal/terms-and-conditions',
'/server-sitemap.xml',
],
robotsTxtOptions: {
additionalSitemaps: [
`${process.env.NEXT_PUBLIC_SITE_URL}/server-sitemap.xml`,
],
},
additionalPaths: async () => {
const defaultChangeFreq = 'daily';
const defaultPriority = 0.7;
const lastMod = new Date().toISOString();
const additionalRoutes = [
{
loc: '/blog',
changefreq: defaultChangeFreq,
priority: defaultPriority,
lastmod: lastMod,
},
];
return additionalRoutes;
},
};Structured Data (JSON-LD)
Article Schema
export default function BlogPost({ params }: { params: { slug: string[] } }) {
const page = blogSource.getPage(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: page.data.title,
description: page.data.description,
image: page.data.img,
datePublished: page.data.publishedAt,
author: {
'@type': 'Person',
name: page.data.author,
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* Post content */}</article>
</>
);
}Organization Schema
import { siteConfig } from '@/config/siteConfig';
import { env } from '@/env';
export default function RootLayout({ children }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Organization',
name: siteConfig.siteName,
url: env.NEXT_PUBLIC_SITE_URL,
logo: `${env.NEXT_PUBLIC_SITE_URL}/logo.png`,
sameAs: [
'https://twitter.com/yourhandle',
'https://github.com/yourorg',
],
};
return (
<html>
<body>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{children}
</body>
</html>
);
}Canonical URLs
Prevent duplicate content:
export function generateMetadata() {
return {
alternates: {
canonical: '/page',
},
};
}Because app/layout.tsx sets metadataBase from env.NEXT_PUBLIC_SITE_URL, relative canonical paths are resolved to the production domain. Use page.url for MDX-backed blog, docs, and legal pages:
return {
title: page?.data?.title,
alternates: {
canonical: page?.url,
},
};For static pages such as /blog and /docs, keep the canonical path in config/siteConfig.ts and reuse siteConfig.metadata.blog.url or siteConfig.metadata.docs.url.
Performance Optimization
Lazy Load Images
<Image
src="/image.jpg"
alt="Description"
width={800}
height={600}
loading="lazy" // Default behavior
/>Optimize Third-Party Scripts
import Script from 'next/script';
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Script
src="https://analytics.example.com/script.js"
strategy="lazyOnload"
/>
</body>
</html>
);
}Testing SEO
Local Testing
View page source (Ctrl+U) to verify:
<title>tag<meta name="description">tag<link rel="canonical">- Open Graph tags
Production Testing
Use these tools after deployment:
- Google Rich Results Test: search.google.com/test/rich-results
- Facebook Sharing Debugger: developers.facebook.com/tools/debug
- Lighthouse: Chrome DevTools → Lighthouse tab
Common Customizations
Custom 404 Page
export const metadata = {
title: '404 - Page Not Found',
description: 'The page you are looking for does not exist.',
};
export default function NotFound() {
return (
<div>
<h1>404 - Page Not Found</h1>
</div>
);
}Exclude Pages from Indexing
export const metadata = {
robots: {
index: false,
follow: false,
},
};Next Steps
- Configuration & Best Practices - SEO configuration guide
- Next.js Metadata API - Complete API reference
- Schema.org - Structured data documentation
How is this guide ?
Last updated on