Skip to content

Examples

Create a full-featured blog with posts, categories, and author management.

schema/blog-post.ts
import { defineModel } from '@scalar/core';
export const blogPost = defineModel({
name: 'blogPost',
label: 'Blog Post',
fields: {
title: {
type: 'text',
required: true,
label: 'Title',
},
slug: {
type: 'slug',
source: 'title',
required: true,
unique: true,
},
content: {
type: 'richtext',
required: true,
label: 'Content',
},
excerpt: {
type: 'textarea',
label: 'Excerpt',
maxLength: 200,
},
featuredImage: {
type: 'image',
label: 'Featured Image',
},
author: {
type: 'relationship',
model: 'author',
required: true,
label: 'Author',
},
categories: {
type: 'relationship',
model: 'category',
multiple: true,
label: 'Categories',
},
tags: {
type: 'array',
of: 'text',
label: 'Tags',
},
publishedAt: {
type: 'datetime',
label: 'Published At',
},
status: {
type: 'select',
options: ['draft', 'published', 'archived'],
defaultValue: 'draft',
label: 'Status',
},
seo: {
type: 'group',
label: 'SEO',
fields: {
metaTitle: { type: 'text', label: 'Meta Title' },
metaDescription: { type: 'textarea', label: 'Meta Description' },
ogImage: { type: 'image', label: 'OG Image' },
},
},
},
});
app/blog/page.tsx
import { scalar } from '@/lib/scalar';
import { BlogCard } from '@/components/blog-card';
export default async function BlogPage() {
const posts = await scalar.content.findMany({
model: 'blogPost',
filter: { status: 'published' },
populate: ['author', 'categories', 'featuredImage'],
sort: '-publishedAt',
limit: 12,
});
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<BlogCard key={post.id} post={post} />
))}
</div>
</div>
);
}
components/blog-card.tsx
import Link from 'next/link';
import Image from 'next/image';
import { formatDate } from '@/lib/utils';
interface BlogCardProps {
post: {
id: string;
title: string;
slug: string;
excerpt: string;
publishedAt: string;
featuredImage?: { url: string; alt: string };
author: { name: string; avatar?: { url: string } };
categories: { name: string; color: string }[];
};
}
export function BlogCard({ post }: BlogCardProps) {
return (
<article className="bg-white rounded-lg shadow-md overflow-hidden">
{post.featuredImage && (
<Link href={`/blog/${post.slug}`}>
<div className="aspect-video relative">
<Image
src={post.featuredImage.url}
alt={post.featuredImage.alt || post.title}
fill
className="object-cover"
/>
</div>
</Link>
)}
<div className="p-6">
<div className="flex flex-wrap gap-2 mb-3">
{post.categories.map((category) => (
<span
key={category.name}
className="px-2 py-1 text-xs rounded-full text-white"
style={{ backgroundColor: category.color }}
>
{category.name}
</span>
))}
</div>
<h2 className="text-xl font-semibold mb-2">
<Link href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</Link>
</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<div className="flex items-center gap-2">
{post.author.avatar && (
<Image
src={post.author.avatar.url}
alt={post.author.name}
width={24}
height={24}
className="rounded-full"
/>
)}
<span>{post.author.name}</span>
</div>
<time>{formatDate(post.publishedAt)}</time>
</div>
</div>
</article>
);
}

Build a product catalog with categories, variants, and inventory management.

schema/product.ts
import { defineModel } from '@scalar/core';
export const product = defineModel({
name: 'product',
label: 'Product',
fields: {
name: {
type: 'text',
required: true,
label: 'Product Name',
},
slug: {
type: 'slug',
source: 'name',
required: true,
unique: true,
},
description: {
type: 'richtext',
label: 'Description',
},
shortDescription: {
type: 'textarea',
label: 'Short Description',
maxLength: 160,
},
images: {
type: 'array',
of: 'image',
label: 'Product Images',
minItems: 1,
},
price: {
type: 'number',
required: true,
min: 0,
label: 'Price',
decimals: 2,
},
comparePrice: {
type: 'number',
min: 0,
label: 'Compare at Price',
decimals: 2,
},
sku: {
type: 'text',
unique: true,
label: 'SKU',
},
inventory: {
type: 'group',
label: 'Inventory',
fields: {
trackQuantity: {
type: 'boolean',
defaultValue: true,
label: 'Track Quantity',
},
quantity: {
type: 'number',
min: 0,
label: 'Quantity',
},
lowStockThreshold: {
type: 'number',
min: 0,
defaultValue: 10,
label: 'Low Stock Threshold',
},
},
},
categories: {
type: 'relationship',
model: 'productCategory',
multiple: true,
label: 'Categories',
},
tags: {
type: 'array',
of: 'text',
label: 'Tags',
},
variants: {
type: 'array',
of: 'group',
label: 'Variants',
fields: {
name: { type: 'text', required: true },
sku: { type: 'text', unique: true },
price: { type: 'number', min: 0, decimals: 2 },
inventory: { type: 'number', min: 0 },
options: {
type: 'json',
label: 'Options',
// e.g., { "color": "red", "size": "large" }
},
},
},
status: {
type: 'select',
options: ['draft', 'active', 'archived'],
defaultValue: 'draft',
label: 'Status',
},
seo: {
type: 'group',
label: 'SEO',
fields: {
metaTitle: { type: 'text', label: 'Meta Title' },
metaDescription: { type: 'textarea', label: 'Meta Description' },
ogImage: { type: 'image', label: 'OG Image' },
},
},
},
hooks: {
beforeCreate: async (data) => {
if (!data.sku) {
data.sku = generateSKU(data.name);
}
},
afterUpdate: async (record, changes) => {
if (changes.inventory?.quantity !== undefined) {
await checkLowStock(record);
}
},
},
});
components/add-to-cart.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { useCart } from '@/hooks/use-cart';
interface AddToCartProps {
product: {
id: string;
name: string;
price: number;
images: { url: string }[];
variants?: Array<{
id: string;
name: string;
price: number;
options: Record<string, string>;
}>;
};
}
export function AddToCart({ product }: AddToCartProps) {
const [selectedVariant, setSelectedVariant] = useState(
product.variants?.[0] || null,
);
const [quantity, setQuantity] = useState(1);
const [loading, setLoading] = useState(false);
const { addItem } = useCart();
const handleAddToCart = async () => {
setLoading(true);
try {
await addItem({
productId: product.id,
variantId: selectedVariant?.id,
quantity,
price: selectedVariant?.price || product.price,
name: selectedVariant
? `${product.name} - ${selectedVariant.name}`
: product.name,
image: product.images[0]?.url,
});
} catch (error) {
console.error('Failed to add to cart:', error);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
{product.variants && (
<div>
<label className="mb-2 block text-sm font-medium">
Select Variant
</label>
<select
value={selectedVariant?.id || ''}
onChange={(e) => {
const variant = product.variants?.find(
(v) => v.id === e.target.value,
);
setSelectedVariant(variant || null);
}}
className="w-full rounded border p-2"
>
{product.variants.map((variant) => (
<option key={variant.id} value={variant.id}>
{variant.name} - ${variant.price}
</option>
))}
</select>
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium">Quantity</label>
<input
type="number"
min="1"
value={quantity}
onChange={(e) => setQuantity(parseInt(e.target.value))}
className="w-20 rounded border p-2"
/>
</div>
<Button onClick={handleAddToCart} disabled={loading} className="w-full">
{loading ? 'Adding...' : 'Add to Cart'}
</Button>
</div>
);
}

Create a portfolio with projects, skills, and contact information.

schema/project.ts
export const project = defineModel({
name: 'project',
label: 'Project',
fields: {
title: {
type: 'text',
required: true,
label: 'Project Title',
},
slug: {
type: 'slug',
source: 'title',
required: true,
unique: true,
},
description: {
type: 'richtext',
required: true,
label: 'Description',
},
summary: {
type: 'textarea',
label: 'Summary',
maxLength: 200,
},
images: {
type: 'array',
of: 'image',
label: 'Project Images',
minItems: 1,
},
technologies: {
type: 'array',
of: 'text',
label: 'Technologies Used',
},
links: {
type: 'group',
label: 'Links',
fields: {
live: { type: 'url', label: 'Live Demo' },
github: { type: 'url', label: 'GitHub Repository' },
case_study: { type: 'url', label: 'Case Study' },
},
},
featured: {
type: 'boolean',
defaultValue: false,
label: 'Featured Project',
},
completedAt: {
type: 'date',
label: 'Completion Date',
},
client: {
type: 'text',
label: 'Client',
},
category: {
type: 'select',
options: ['web', 'mobile', 'desktop', 'api', 'design'],
label: 'Category',
},
},
});

Build a property listing site with advanced filtering.

schema/property.ts
export const property = defineModel({
name: 'property',
label: 'Property',
fields: {
title: {
type: 'text',
required: true,
label: 'Property Title',
},
address: {
type: 'group',
required: true,
label: 'Address',
fields: {
street: { type: 'text', required: true, label: 'Street Address' },
city: { type: 'text', required: true, label: 'City' },
state: { type: 'text', required: true, label: 'State' },
zipCode: { type: 'text', required: true, label: 'ZIP Code' },
country: { type: 'text', defaultValue: 'USA', label: 'Country' },
},
},
location: {
type: 'geo',
required: true,
label: 'Map Location',
},
price: {
type: 'number',
required: true,
min: 0,
label: 'Price',
decimals: 0,
},
propertyType: {
type: 'select',
required: true,
options: ['house', 'apartment', 'condo', 'townhouse', 'land'],
label: 'Property Type',
},
listingType: {
type: 'select',
required: true,
options: ['sale', 'rent'],
label: 'Listing Type',
},
bedrooms: {
type: 'number',
min: 0,
label: 'Bedrooms',
},
bathrooms: {
type: 'number',
min: 0,
decimals: 1,
label: 'Bathrooms',
},
squareFootage: {
type: 'number',
min: 0,
label: 'Square Footage',
},
lotSize: {
type: 'number',
min: 0,
label: 'Lot Size (sq ft)',
},
yearBuilt: {
type: 'number',
min: 1800,
max: new Date().getFullYear() + 1,
label: 'Year Built',
},
features: {
type: 'array',
of: 'text',
label: 'Features',
},
images: {
type: 'array',
of: 'image',
label: 'Property Images',
minItems: 1,
},
virtualTour: {
type: 'url',
label: 'Virtual Tour URL',
},
agent: {
type: 'relationship',
model: 'agent',
required: true,
label: 'Listing Agent',
},
status: {
type: 'select',
options: ['active', 'pending', 'sold', 'withdrawn'],
defaultValue: 'active',
label: 'Listing Status',
},
},
});
components/property-search.tsx
'use client';
import { useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export function PropertySearch() {
const router = useRouter();
const searchParams = useSearchParams();
const [filters, setFilters] = useState({
propertyType: searchParams.get('propertyType') || '',
minPrice: searchParams.get('minPrice') || '',
maxPrice: searchParams.get('maxPrice') || '',
bedrooms: searchParams.get('bedrooms') || '',
bathrooms: searchParams.get('bathrooms') || '',
city: searchParams.get('city') || '',
});
const handleSearch = () => {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) {
params.set(key, value);
}
});
router.push(`/properties?${params.toString()}`);
};
return (
<div className="rounded-lg bg-white p-6 shadow-md">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-6">
<select
value={filters.propertyType}
onChange={(e) =>
setFilters((prev) => ({ ...prev, propertyType: e.target.value }))
}
className="rounded border p-2"
>
<option value="">All Types</option>
<option value="house">House</option>
<option value="apartment">Apartment</option>
<option value="condo">Condo</option>
</select>
<input
type="number"
placeholder="Min Price"
value={filters.minPrice}
onChange={(e) =>
setFilters((prev) => ({ ...prev, minPrice: e.target.value }))
}
className="rounded border p-2"
/>
<input
type="number"
placeholder="Max Price"
value={filters.maxPrice}
onChange={(e) =>
setFilters((prev) => ({ ...prev, maxPrice: e.target.value }))
}
className="rounded border p-2"
/>
<select
value={filters.bedrooms}
onChange={(e) =>
setFilters((prev) => ({ ...prev, bedrooms: e.target.value }))
}
className="rounded border p-2"
>
<option value="">Bedrooms</option>
<option value="1">1+</option>
<option value="2">2+</option>
<option value="3">3+</option>
<option value="4">4+</option>
</select>
<input
type="text"
placeholder="City"
value={filters.city}
onChange={(e) =>
setFilters((prev) => ({ ...prev, city: e.target.value }))
}
className="rounded border p-2"
/>
<button
onClick={handleSearch}
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Search
</button>
</div>
</div>
);
}
components/pagination.tsx
'use client';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
interface PaginationProps {
currentPage: number;
totalPages: number;
baseUrl: string;
}
export function Pagination({
currentPage,
totalPages,
baseUrl,
}: PaginationProps) {
const searchParams = useSearchParams();
const createPageUrl = (page: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set('page', page.toString());
return `${baseUrl}?${params.toString()}`;
};
return (
<nav className="flex justify-center space-x-2">
{currentPage > 1 && (
<Link
href={createPageUrl(currentPage - 1)}
className="rounded bg-gray-200 px-3 py-2 hover:bg-gray-300"
>
Previous
</Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Link
key={page}
href={createPageUrl(page)}
className={`rounded px-3 py-2 ${
page === currentPage
? 'bg-blue-600 text-white'
: 'bg-gray-200 hover:bg-gray-300'
}`}
>
{page}
</Link>
))}
{currentPage < totalPages && (
<Link
href={createPageUrl(currentPage + 1)}
className="rounded bg-gray-200 px-3 py-2 hover:bg-gray-300"
>
Next
</Link>
)}
</nav>
);
}

Blog Starter

E-commerce Template

Portfolio Site

Real Estate Platform