Schema-First Next.js: Build CMS-Ready Sites in 5 Steps
Define TypeScript data schemas first, build dumb components, and swap any headless CMS (Payload, Strapi, Sanity)…

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I was building a manufacturing website for a client recently when I hit a familiar wall: halfway through development, I needed to add a new field to a section. That meant updating the component, changing the data structure, adjusting types, and refactoring everywhere those types were used. After hours of painful rewiring, I realized I'd been building backwards the entire time.
The real problem wasn't the new field—it was that I'd never properly defined my data schema. Components were directly accessing hardcoded values. Types weren't enforced as contracts. I was building a one-off website, not a content-managed system.
This guide shows you the exact approach I developed to fix that: define your data schema as TypeScript types FIRST (in app/data.ts), treat it like the output of a headless CMS codegen, build components that receive and display typed data, and when you eventually add a CMS, only the data source changes. Components stay the same. Types stay the same. Everything just works.
By the end of this guide, you'll have a website where data structure and components are completely decoupled—making it trivially easy to swap any headless CMS in later (Payload, Strapi, Sanity, WordPress, Contentful, etc.) without touching your UI layer.
The Problem: Data and Components Get Tangled
Most developers start like this:
- Build a component without thinking about data structure
- Hardcode content directly in JSX
- Realize the component needs to accept props
- Hastily create a type that matches the component
- Add more content, realize the type doesn't fit
- Refactor everything (repeat 2-5)
I did this for years. Every project followed the same pattern. Adding a new content section meant inventing a type on the fly, building the component around it, and then realizing it didn't align with other sections. No consistency. No contract. Just chaotic coupling.
The costs add up fast. A new field means updating the component, everywhere the type is used, any parent components that pass data, and your mental model of how data flows. One bad decision early haunts you for months.
Then I realized something obvious: I should define my data schema FIRST, before building anything. Just like Payload CMS generates types from your collections, I should define what my data looks like in app/data.ts—as TypeScript interfaces—and then build components that simply receive and display that data. The schema is the contract. Everything else flows from that.
The Solution: Schema First, Components Second, Everything Else Third
Schema-first development flips the sequence:
- Define your data schema in
app/data.tsas TypeScript interfaces - Create example data that matches those interfaces exactly
- Build components that accept data via props and display it
- Everything works on day one—no refactoring needed
- When you add a CMS later, only the data source changes
This works because your schema is your specification. It defines exactly what data exists, what's required versus optional, what structure it has, and how it relates to other elements. Once you have the schema locked down, components are trivial—they're just presentation layers.
The schema is the contract. Components are dumb. Data is king.
A Real Example: The Manufacturing Website
Let me walk you through a concrete example. I recently built a website for Orodjarstvo Hafner, a precision metal manufacturing company in Slovenia with over 40 years of history. The design had multiple sections: hero, about us, services, gallery, solutions, testimonials, and contact.
Here's how I approached it.
Step 1: Define Your Data Schema
Before touching Figma or components, I sat down and defined exactly what data each section needed. For the hero section:
- A background video URL
- A heading (text)
- A description (text)
- Two buttons with labels, URLs, and variants
For the about section:
- A tagline (text)
- A heading and description (text)
- A list of items (array of text)
- Two image URLs with specific dimensions (500x500 and 280x280)
For services:
- A tagline and heading (text)
- A list of service cards, each with a heading and description
- A call-to-action with text and link
This is just planning. No components yet. No guessing. Just thinking: "What data does this section actually need?"
Step 2: Create TypeScript Interfaces (Your Schema)
Now I convert that planning into TypeScript interfaces. These interfaces ARE my data schema—exactly like what Payload CMS would generate from its collection configs.
// File: app/data.ts
/**
* Button configuration
*/
export interface Button {
label: string;
href?: string;
variant?: "primary" | "outlined";
}
/**
* Hero section configuration
* Drives the SectionHero component
*/
export interface HeroSection {
backgroundVideo: string;
heading: string;
description: string;
buttons: [Button, Button]; // Primary and secondary buttons
}
/**
* Example data for Hero section
* This matches the Figma design exactly
*/
export const heroExample: HeroSection = {
backgroundVideo: "/_videos/v1/6a62a6169778c09d24d831f2ab9179008509f1c8",
heading: "Precision metal manufacturing for industry leaders",
description:
"We craft metal parts with uncompromising quality. Our four decades of engineering expertise transform complex challenges into reliable industrial solutions.",
buttons: [
{
label: "Explore Services",
href: "/services",
variant: "primary",
},
{
label: "Contact Us",
href: "/contact",
variant: "outlined",
},
],
};
Notice what's happening here: HeroSection IS my data contract. It defines exactly what fields exist, what types they have, and what's required versus optional. The heroExample object serves two purposes: it's test data AND it's documentation showing exactly how to use this type.
If I were using Payload CMS, this interface would be generated from a collection config. Since I'm not (yet), I define it manually. Either way, the interface is the source of truth.
For the about section:
/**
* List item configuration for AboutUs section
*/
export interface AboutListItem {
text: string;
}
/**
* AboutUs section configuration
* Drives the AboutUs component
*/
export interface AboutUsSection {
tagline: string;
heading: string;
description: string;
listItems: AboutListItem[];
images: {
main: string; // Large main image (500x500)
secondary: string; // Smaller secondary image (280x280)
};
}
/**
* Example data for AboutUs section
* This matches the schema exactly
*/
export const aboutUsExample: AboutUsSection = {
tagline: "Experience",
heading: "Crafted in Slovenia. Trusted Across Europe.",
description: `Founded as a family-owned workshop more than four decades ago, Orodjarstvo Hafner has grown into a trusted manufacturing partner for precision tooling, prototyping, and automation components.
Our experience and flexibility allow us to respond quickly to custom needs — from single prototypes to small-series production.`,
listItems: [
{ text: "40+ years of experience" },
{ text: "Family-owned and operated" },
{ text: "80% of production exported to Austria and Germany" },
{ text: "Modern CNC and EDM machinery" },
],
images: {
main: "http://localhost:3845/assets/36d9c762c055a09f1c051c3e18def3bdb61dbb0b.png",
secondary:
"http://localhost:3845/assets/feb49c1a3cdcef66ec7f1028ae913e1353eaab5d.png",
},
};
Again, every field is intentional. No guessing. No invented properties. The schema is locked down before I write a single component.
For services, which is more complex:
/**
* Service card configuration
*/
export interface ServiceCard {
heading: string;
description: string;
}
/**
* Services section configuration
* Drives the Services component
*/
export interface ServicesSection {
tagline: string;
heading: string;
subtitle: string;
services: ServiceCard[];
cta: {
text: string;
linkText: string;
href: string;
};
}
/**
* Example data for Services section
* This matches the Figma design exactly
*/
export const servicesExample: ServicesSection = {
tagline: "Our Capabilities",
heading: "Advanced manufacturing solutions",
subtitle: "Transforming industrial challenges with cutting-edge technology and expertise",
services: [
{
heading: "CNC milling and turning",
description:
"High-precision milling on modern CNC equipment.\n\n**Capacity:** up to 1020 × 600 × 600 mm\n\nIdeal for complex parts, jigs, and fixtures.",
},
{
heading: "CNC Turning",
description:
"Efficient for shafts, bushings, and custom round parts.\n\n**Capacity:** CNC up to Ø300 × 490 mm, conventional up to Ø500 × 1000 mm.",
},
{
heading: "Wire EDM",
description:
"Fine cutting for intricate shapes and hard materials. Perfect for precision profiles, dies, and small production runs.\n\n**Capacity:** 560 × 360 × 290 mm",
},
{
heading: "Surface & Cylindrical Grinding",
description:
"Achieving the final touch of precision.\n\n**Capacity:** Surface grinding 200 × 600 mm, cylindrical Ø200 × 600 mm.",
},
{
heading: "Tool Manufacturing",
description:
"Custom tools, gripper attachments, and fixtures for automation systems. Designed and produced according to your drawings or 3D models.",
},
{
heading: "Prototyping & Small-Series Production",
description:
"Fast and flexible support for development and testing. We produce one-off parts or small batches with consistent quality and fast turnaround.",
},
],
cta: {
text: "Ready to discuss your project?",
linkText: "Send Inquiry",
href: "/contact",
},
};
At this point, I've defined my entire data structure. The interfaces are locked down. If someone asks "what fields does a service have?" the answer is right there in the ServiceCard interface. If someone wonders "what data does the hero need?" the answer is in HeroSection.
This is crucial: My schema is now my specification. Not Figma. Not wireframes. Not someone's explanation. The TypeScript interfaces are the single source of truth for what data exists and how it's structured.
Step 3: Build Components That Display the Types
Now comes the easy part. Components don't decide what data exists. They receive data via props and display what the types tell them to display.
// File: app/components/hero.tsx
"use client";
import Link from "next/link";
import { HeroSection } from "@/app/data";
interface HeroProps {
data: HeroSection;
}
export default function Hero({ data }: HeroProps) {
return (
<section className="relative w-full bg-white">
{/* Background Video Container */}
<div className="relative flex h-[640px] w-full items-center justify-start overflow-hidden rounded-lg">
{/* Video Background */}
<video
autoPlay
className="absolute inset-0 h-full w-full object-cover"
controlsList="nodownload"
loop
muted
playsInline
>
<source src={data.backgroundVideo} type="video/mp4" />
</video>
{/* Overlay Content */}
<div className="relative z-10 flex flex-col gap-6 px-16 py-0">
{/* Heading and Description Group */}
<div className="flex flex-col gap-6 max-w-[720px]">
<h1 className="text-5xl font-bold leading-tight text-white">
{data.heading}
</h1>
<p className="text-lg leading-relaxed text-white">
{data.description}
</p>
</div>
{/* Button Group */}
<div className="flex gap-4 pt-4">
{data.buttons.map((button, index) => (
<Link
key={index}
href={button.href || "#"}
className={`px-6 py-3 font-medium transition-colors ${
button.variant === "outlined"
? "border border-white text-white hover:bg-white/10"
: "bg-orange-500 text-white hover:bg-orange-600"
}`}
>
{button.label}
</Link>
))}
</div>
</div>
</div>
</section>
);
}
The component doesn't know anything about business logic. It receives HeroSection data and displays it. The type system tells the component what fields exist. If you ever change the type, TypeScript will immediately tell you that the component needs updating.
This is the magic: the type IS the contract between data and UI. No surprises. No misalignments.
For the about section:
// File: app/components/about-us.tsx
"use client";
import Image from "next/image";
import { AboutUsSection } from "@/app/data";
import { CheckCircle } from "lucide-react";
interface AboutUsProps {
data: AboutUsSection;
}
export default function AboutUs({ data }: AboutUsProps) {
return (
<section className="bg-white px-10 py-24">
<div className="mx-auto flex max-w-5xl gap-20">
{/* Left Column */}
<div className="flex flex-col gap-8 flex-1">
{/* Tagline */}
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-orange-500">
{data.tagline}
</p>
</div>
{/* Heading and Description */}
<div className="flex flex-col gap-6">
<h2 className="text-4xl font-semibold leading-tight text-gray-900">
{data.heading}
</h2>
<div className="text-base leading-relaxed text-gray-900 space-y-3">
{data.description.split("\n\n").map((paragraph, idx) => (
<p key={idx}>{paragraph}</p>
))}
</div>
</div>
{/* List Items */}
<div className="flex flex-col gap-5">
{data.listItems.map((item, idx) => (
<div key={idx} className="flex items-start gap-4">
<CheckCircle className="h-6 w-6 flex-shrink-0 text-orange-500" />
<p className="text-base text-gray-900">{item.text}</p>
</div>
))}
</div>
</div>
{/* Right Column - Images */}
<div className="relative flex flex-1 items-center justify-center h-[629px]">
{/* Main Image (Top Right) */}
<div className="absolute right-0 top-0 h-[500px] w-[500px] overflow-hidden rounded-2xl">
<Image
src={data.images.main}
alt="Manufacturing facility"
width={500}
height={500}
className="h-full w-full object-cover"
/>
</div>
{/* Secondary Image (Bottom Left) */}
<div className="absolute bottom-0 left-0 h-[280px] w-[280px] overflow-hidden rounded-2xl border-8 border-white">
<Image
src={data.images.secondary}
alt="Metal parts detail"
width={280}
height={280}
className="h-full w-full object-cover"
/>
</div>
</div>
</div>
</section>
);
}
Again, the component is dumb. It receives AboutUsSection data and displays it. No business logic. No guessing. Just pure presentation driven by the type.
Step 4: Wire Everything Into Your Layout
In your main layout and page, you import the data and pass it to components:
// File: app/layout.tsx
import type { Metadata } from "next";
import Navbar from "@/app/components/navbar";
import Footer from "@/app/components/footer";
import { navbarExample, footerExample } from "@/app/data";
import "./globals.css";
export const metadata: Metadata = {
title: "Orodjarstvo Hafner - Precision Metal Manufacturing",
description: "Precision metal manufacturing for industry leaders.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html>
<body>
<Navbar data={navbarExample} />
{children}
<Footer data={footerExample} />
</body>
</html>
);
}
// File: app/page.tsx
import Hero from "./components/hero";
import AboutUs from "./components/about-us";
import Services from "./components/services";
import { heroExample, aboutUsExample, servicesExample } from "./data";
export default function Home() {
return (
<main>
<Hero data={heroExample} />
<AboutUs data={aboutUsExample} />
<Services data={servicesExample} />
</main>
);
}
That's it. Your website is now driven by types that represent your data structure. Every piece of content is in app/data.ts. Components are just presentation layers. Everything is type-safe.
The Headless CMS Integration: Where It Gets Magical
Now here's where this approach reveals its power. Months later, you decide to add a headless CMS to let clients manage their own content. Whether you choose Payload CMS, Strapi, Sanity, WordPress, Contentful, or any other CMS—integration is trivial.
Before (hardcoded data):
import { heroExample } from "@/app/data";
<Hero data={heroExample} />
After (any Headless CMS):
// Payload CMS example
const heroData = await fetchFromPayload('hero');
// OR Strapi
const heroData = await fetch('https://api.strapi.example.com/api/hero').then(r => r.json());
// OR Sanity
const heroData = await sanityClient.fetch('*[_type == "hero"][0]');
// OR WordPress with WPGraphQL
const heroData = await fetch('graphql', { /* query */ }).then(r => r.json());
// The type is the SAME. No changes needed.
<Hero data={heroData} />
Your component doesn't change. Your types don't change. Only the data source changes.
Why is this possible? Because your schema in app/data.ts was already the contract. You didn't guess at the data structure. You defined it explicitly. When you created Payload collections, you simply matched those interfaces. Your data layer and your UI layer were always separate.
This is the real power of schema-first development: You built the architecture correctly from day one. When you integrate a CMS, there's no refactoring needed. The schema already existed. The components already expected typed data. You just swap the data source and everything works.
Multi-Language Support: Adding Next-Intl
Once you have this structure, adding multi-language support is surprisingly clean. You'll use next-intl to handle translations while keeping your component architecture intact.
Step 1: Update Your Types for Localization
Modify your types to support multiple languages. The simplest approach is to have translation keys instead of hardcoded strings:
// File: app/data.ts
/**
* Translatable Hero section
*/
export interface HeroSection {
backgroundVideo: string;
headingKey: string; // Translation key instead of hardcoded text
descriptionKey: string;
buttons: [Button, Button];
}
export const heroExample: HeroSection = {
backgroundVideo: "/_videos/v1/6a62a6169778c09d24d831f2ab9179008509f1c8",
headingKey: "hero.heading", // Reference to translation
descriptionKey: "hero.description",
buttons: [
{
label: "Explore Services",
href: "/services",
variant: "primary",
},
{
label: "Contact Us",
href: "/contact",
variant: "outlined",
},
],
};
Actually, a better approach is to handle translations at the component level, not the data level. Keep your data simple and let components handle i18n:
// File: app/data.ts
// Keep types simple - just structure, not language
export interface HeroSection {
backgroundVideo: string;
heading: string;
description: string;
buttons: [Button, Button];
}
Then in your component, use next-intl:
// File: app/components/hero.tsx
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { HeroSection } from "@/app/data";
interface HeroProps {
data: HeroSection;
}
export default function Hero({ data }: HeroProps) {
const t = useTranslations();
return (
<section className="relative w-full bg-white">
<div className="relative flex h-[640px] w-full items-center justify-start overflow-hidden rounded-lg">
<video
autoPlay
className="absolute inset-0 h-full w-full object-cover"
controlsList="nodownload"
loop
muted
playsInline
>
<source src={data.backgroundVideo} type="video/mp4" />
</video>
<div className="relative z-10 flex flex-col gap-6 px-16 py-0">
<div className="flex flex-col gap-6 max-w-[720px]">
<h1 className="text-5xl font-bold leading-tight text-white">
{t("hero.heading")}
</h1>
<p className="text-lg leading-relaxed text-white">
{t("hero.description")}
</p>
</div>
<div className="flex gap-4 pt-4">
{data.buttons.map((button, index) => (
<Link
key={index}
href={button.href || "#"}
className={`px-6 py-3 font-medium transition-colors ${
button.variant === "outlined"
? "border border-white text-white hover:bg-white/10"
: "bg-orange-500 text-white hover:bg-orange-600"
}`}
>
{t(`hero.button.${button.label.toLowerCase().replace(/\s/g, "_")}`)}
</Link>
))}
</div>
</div>
</div>
</section>
);
}
Step 2: Create Translation Files
Set up your translation structure. With next-intl, you'll create language-specific JSON files:
// File: messages/en.json
{
"hero": {
"heading": "Precision metal manufacturing for industry leaders",
"description": "We craft metal parts with uncompromising quality. Our four decades of engineering expertise transform complex challenges into reliable industrial solutions.",
"button": {
"explore_services": "Explore Services",
"contact_us": "Contact Us"
}
},
"about": {
"tagline": "Experience",
"heading": "Crafted in Slovenia. Trusted Across Europe.",
"description": "Founded as a family-owned workshop more than four decades ago, Orodjarstvo Hafner has grown into a trusted manufacturing partner for precision tooling, prototyping, and automation components.\n\nOur experience and flexibility allow us to respond quickly to custom needs — from single prototypes to small-series production."
}
}
// File: messages/de.json
{
"hero": {
"heading": "Präzisionsmetallverarbeitung für Industrieleader",
"description": "Wir stellen Metallteile mit hoher Qualität her. Unsere vierjahrzehnte lange Ingenieurskunst transformiert komplexe Herausforderungen in zuverlässige Industrielösungen.",
"button": {
"explore_services": "Services Erkunden",
"contact_us": "Kontaktieren Sie Uns"
}
}
}
Step 3: Configure Next-Intl
Set up your next.config.ts and create middleware:
// File: next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "3845",
pathname: "/**",
},
],
},
};
export default nextConfig;
// File: middleware.ts
import createMiddleware from "next-intl/middleware";
export default createMiddleware({
locales: ["en", "de", "sl"],
defaultLocale: "en",
localePrefix: "as-needed",
});
export const config = {
matcher: ["/((?!api|_next|.*\\..*).*)"],
};
Step 4: Update Your App Structure for Localized Routes
With next-intl, your app structure becomes locale-aware:
app/
├── [locale]/
│ ├── page.tsx // Home page
│ ├── layout.tsx // Layout with locale-aware Navbar/Footer
│ └── components/
│ ├── hero.tsx
│ ├── about-us.tsx
│ └── ...
├── data.ts // Data (language-agnostic)
└── globals.css
Your localized page then uses the same components:
// File: app/[locale]/page.tsx
import { useLocale } from "next-intl";
import Hero from "@/app/components/hero";
import AboutUs from "@/app/components/about-us";
import Services from "@/app/components/services";
import { heroExample, aboutUsExample, servicesExample } from "@/app/data";
export default function Home() {
const locale = useLocale();
return (
<main>
<Hero data={heroExample} />
<AboutUs data={aboutUsExample} />
<Services data={servicesExample} />
</main>
);
}
The beautiful part: your data structure doesn't change. Your components stay the same. The locale is handled transparently by the middleware and next-intl hooks.
Users visiting /en, /de, or /sl automatically get the right language. Components use useTranslations() to pull the correct strings. No data structure changes needed.
The Key Principles That Make This Work
This approach succeeds because of a few core principles:
Schema is your specification. Define your data structure in app/data.ts FIRST, before components. The interfaces ARE your contract—just like Payload CMS generates from collection configs. No guessing.
Types are locked down before components. Your TypeScript interfaces define what fields exist, what's required, what types they have. This is the single source of truth. Components never invent data—they just display what the types tell them to.
Components are dumb renderers. Components don't decide what data exists. They accept data via typed props and render it. That's it. No business logic. No data fetching. Just pure presentation.
Example data documents the schema. The heroExample, aboutUsExample, etc. objects serve as both test data AND documentation. They show exactly how to use each interface. When someone asks "what does a HeroSection look like?" they can read the interface and the example.
Data layer is separate from UI layer. Data lives in app/data.ts. UI lives in components. They're decoupled. The data structure never depends on how it's displayed. The component never knows where data comes from.
CMS swap is just a data source change. Your types define the contract. When you integrate Payload, Strapi, or any CMS, you simply create collections that match your interfaces. Components don't change. Types don't change. Only the fetch logic changes.
Translation is presentation, not data. Language-specific content lives in translation files, not your data types. The schema stays language-agnostic. Components use next-intl to pull translated strings at render time.
Conclusion
You've just learned how to structure a Next.js website by defining your data schema FIRST—treating app/data.ts as if it's the output of a headless CMS codegen tool. Before any component exists, before you touch Figma, before you even think about styling, you've locked down exactly what data your website needs and how it's structured.
This separation is the key. When you eventually integrate Payload CMS (or Strapi, Sanity, etc.), there's nothing to refactor. Your types already defined the contract. You just change where the data comes from.
Your architecture is now:
- Schema-driven (
app/data.tsis your source of truth) - Decoupled (data layer and UI layer are completely separate)
- Type-safe (TypeScript enforces the contract on both ends)
- CMS-ready (swap the data source, keep everything else)
- Multi-language capable (translations live separately from schema)
- Scalable (adding new sections is just adding new interfaces and components)
The real magic is that you're not building a prototype. You're building production-ready architecture from day one, with CMS integration already baked in before you've written a single line of Figma-driven CSS.
When you eventually connect Payload CMS, you'll create collections that match your interfaces. You'll fill the admin panel. Users will manage content. Your components will still just receive data and render it. That's when you realize this wasn't about design or CMS at all—it was about defining your specification correctly before you started building.
Let me know in the comments if you have questions about this approach, and subscribe for more practical development guides.
Thanks, Matija
Appendix: Complete File Structure Reference
Here's what your final project structure looks like:
hafner-website/
├── app/
│ ├── [locale]/
│ │ ├── page.tsx
│ │ └── layout.tsx
│ ├── components/
│ │ ├── navbar.tsx
│ │ ├── hero.tsx
│ │ ├── about-us.tsx
│ │ ├── services.tsx
│ │ ├── gallery.tsx
│ │ ├── solutions.tsx
│ │ ├── testimonials.tsx
│ │ ├── contact.tsx
│ │ └── footer.tsx
│ ├── data.ts # All types and example data
│ ├── layout.tsx # Root layout
│ └── globals.css # Design tokens
├── components/
│ └── ui/ # shadcn/ui components
├── messages/
│ ├── en.json
│ ├── de.json
│ └── sl.json
├── middleware.ts # next-intl middleware
├── next.config.ts
├── tsconfig.json
└── package.json
This structure keeps all your data in one place (data.ts), all translations in messages/, all components organized, and leverages Next.js app router with locale support baked in.