SEO

How to configure site identity, page metadata, JSON-LD structured data, OG images, robots, and sitemap.

1. Site Identity

All site-wide values are centralized in a single config file. Edit this to change your name, job title, description, and social links. Every page references this config.

1// website/src/shared/config/site.config.ts
2
3export const siteConfig = {
4 name: "Portfolio",
5 author: "Erik Yuntantyo",
6 email: "erik.yuntantyo@gmail.com",
7 jobTitle: "Software Engineer",
8 description: "Full-stack developer portfolio...",
9 url: "https://erikyuntantyo.com",
10 keywords: ["software engineer", "portfolio", "next.js", "react"],
11 social: {
12 github: "https://github.com/erikyuntantyo",
13 linkedin: "https://linkedin.com/in/erikyuntantyo",
14 twitter: "https://twitter.com/erikyuntantyo",
15 },
16};

2. Page Metadata

Every page must call generatePageMetadata() from @/server/seo/seo. This generates the title, description, openGraph, and twitter metadata automatically. No per-page manual meta tags needed.

1// app/about/page.tsx
2import { generatePageMetadata } from "@/server/seo/seo";
3
4export const metadata = generatePageMetadata({
5 title: "About",
6 description: "Full-stack developer with experience in...",
7 path: "/about",
8});

3. JSON-LD Structured Data

JSON-LD is generated per page type using helper functions. This tells search engines the type and structure of each page (website, person, blog, article, etc.).

1import {
2 generateWebsiteJsonLd,
3 generatePersonJsonLd,
4 generateBlogJsonLd,
5 generateBlogPostJsonLd,
6 generateProfilePageJsonLd,
7 generateBreadcrumbJsonLd,
8} from "@/server/seo/seo";
9
10// Root layout — WebSite + Person
11<script type="application/ld+json">
12 {JSON.stringify(generateWebsiteJsonLd())}
13</script>
14<script type="application/ld+json">
15 {JSON.stringify(generatePersonJsonLd())}
16</script>
17
18// Blog listing page
19generateBlogJsonLd(posts)
20
21// Blog post page
22generateBlogPostJsonLd({ title, description, url, datePublished })
23
24// About page
25generateProfilePageJsonLd()
26
27// Any page with breadcrumbs
28generateBreadcrumbJsonLd([
29 { name: "Home", url: "/" },
30 { name: "Blog", url: "/blog" },
31 { name: postTitle, url: `/blog/${slug}` },
32])

4. OG & Twitter Images

Dynamic images are generated at build time using Next.js ImageResponse. They automatically include the site name, page title, and branding. No manual image creation needed.

1// app/opengraph-image.tsx — generates OG image (1200x630)
2// app/twitter-image.tsx — generates Twitter card image
3
4// No runtime export needed — Node default (Next.js 16 native):
5export const contentType = "image/png";
6export const size = { width: 1200, height: 630 };

5. Robots & Sitemap

Both are generated dynamically. robots.ts controls crawler access. sitemap.ts auto-includes all blog posts. The case study app uses noindex, nofollow with Disallow: / since it's a template catalog, not a public site.

1// app/robots.ts — website (allow all)
2export default function robots() {
3 return {
4 rules: { userAgent: "*", allow: "/" },
5 sitemap: "https://erikyuntantyo.com/sitemap.xml",
6 };
7}
8
9// app/sitemap.ts — auto-includes blog posts
10export default async function sitemap() {
11 const posts = getAllPosts();
12 return [
13 { url: "https://erikyuntantyo.com", lastModified: new Date() },
14 { url: "https://erikyuntantyo.com/about", lastModified: new Date() },
15 { url: "https://erikyuntantyo.com/blog", lastModified: new Date() },
16 ...posts.map((post) => ({
17 url: `https://erikyuntantyo.com/blog/${post.slug}`,
18 lastModified: new Date(post.date),
19 })),
20 ];
21}

Note: The case study app is intentionally hidden from search engines. Its robots.ts uses Disallow: / and all pages have noindex, nofollow. Only the website is meant to be indexed.

Built with Next.js, deployed from a couch in Indonesia.

© 2025 Erik Yuntantyo · turmerrific