Scalar LogoScalar
Guides & Tutorials

Examples

Real-world examples and use cases for Scalar CMS

Blog Website

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

Content Models

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' },
      },
    },
  },
});
schema/category.ts
import { defineModel } from '@scalar/core';

export const category = defineModel({
  name: 'category',
  label: 'Category',

  fields: {
    name: {
      type: 'text',
      required: true,
      label: 'Name',
    },

    slug: {
      type: 'slug',
      source: 'name',
      required: true,
      unique: true,
    },

    description: {
      type: 'textarea',
      label: 'Description',
    },

    color: {
      type: 'color',
      label: 'Color',
      defaultValue: '#3b82f6',
    },

    parent: {
      type: 'relationship',
      model: 'category',
      label: 'Parent Category',
    },
  },
});
schema/author.ts
import { defineModel } from '@scalar/core';

export const author = defineModel({
  name: 'author',
  label: 'Author',

  fields: {
    name: {
      type: 'text',
      required: true,
      label: 'Name',
    },

    email: {
      type: 'email',
      required: true,
      unique: true,
      label: 'Email',
    },

    bio: {
      type: 'textarea',
      label: 'Bio',
    },

    avatar: {
      type: 'image',
      label: 'Avatar',
    },

    socialLinks: {
      type: 'group',
      label: 'Social Links',
      fields: {
        twitter: { type: 'url', label: 'Twitter' },
        linkedin: { type: 'url', label: 'LinkedIn' },
        github: { type: 'url', label: 'GitHub' },
        website: { type: 'url', label: 'Website' },
      },
    },
  },
});

Frontend Implementation

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>
  );
}
pages/blog/index.vue
<template>
  <div class="container mx-auto px-4 py-8">
    <h1 class="text-4xl font-bold mb-8">Blog</h1>
    
    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      <BlogCard 
        v-for="post in posts" 
        :key="post.id" 
        :post="post" 
      />
    </div>
  </div>
</template>

<script setup>
const { $scalar } = useNuxtApp();

const { data: posts } = await $scalar.content.findMany({
  model: 'blogPost',
  filter: { status: 'published' },
  populate: ['author', 'categories', 'featuredImage'],
  sort: '-publishedAt',
  limit: 12,
});

useSeoMeta({
  title: 'Blog',
  description: 'Read our latest blog posts and insights',
});
</script>
src/routes/blog/+page.server.ts
import { scalar } from '$lib/scalar';

export async function load() {
  const posts = await scalar.content.findMany({
    model: 'blogPost',
    filter: { status: 'published' },
    populate: ['author', 'categories', 'featuredImage'],
    sort: '-publishedAt',
    limit: 12,
  });

  return { posts };
}
src/routes/blog/+page.svelte
<script>
  import BlogCard from '$lib/components/BlogCard.svelte';
  export let data;
</script>

<div class="container mx-auto px-4 py-8">
  <h1 class="text-4xl font-bold mb-8">Blog</h1>

  <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
    {#each data.posts as post}
      <BlogCard {post} />
    {/each}
  </div>
</div>

E-commerce Store

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

Product Model

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);
      }
    },
  },
});

Shopping Cart Implementation

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>
  );
}

Portfolio Website

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

Project Model

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',
    },
  },
});

Real Estate Listings

Build a property listing site with advanced filtering.

Property Model

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',
    },
  },
});

Common Patterns

Search and Filtering

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>
  );
}

Pagination Component

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>
  );
}

All examples are available as starter templates. Use npx scalar create --template [template-name] to get started quickly.