Skip to content

Entities Overview

Entities are the fundamental building blocks of content in Stelo CMS. They provide a structured, type-safe way to manage all types of content for your client websites.

Stelo CMS organizes content into four main entity types:

Administrative users who manage content in the CMS.

  • Admins: Full access to all content and settings
  • Editors: Content creation and editing permissions
  • Viewers: Read-only access for stakeholders

Static content pages with fixed URLs and purpose.

  • About Us: Company information and history
  • Contact: Contact forms and information
  • Privacy Policy: Legal pages and policies
  • Landing Pages: Marketing and campaign pages

Dynamic content types that can have multiple entries.

  • Services: Business offerings and descriptions
  • Blog Posts: Articles and news content
  • Team Members: Staff profiles and bios
  • Testimonials: Client reviews and feedback
  • Products: E-commerce catalog items

Site-wide settings and reusable content.

  • Header: Navigation menus and branding
  • Footer: Links, contact info, and social media
  • SEO Settings: Default meta tags and analytics
  • Contact Information: Phone, email, address

All entities follow a consistent schema pattern:

model EntityName {
id String @id @default(cuid())
slug String @unique
title Json // Localized content
content Json // Localized content
metadata Json? // SEO and additional data
published Boolean @default(false)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relationships
authorId String?
author User? @relation(fields: [authorId], references: [id])
// Indexes for performance
@@index([published])
@@index([slug])
@@index([createdAt])
}

Every content field supports multiple locales using JSON columns:

// Example: Multi-language title
title: {
en: "Welcome to Our Website",
fr: "Bienvenue sur notre site",
es: "Bienvenido a nuestro sitio"
}
// Example: Rich content with images
content: {
en: {
body: "<p>Welcome to our company...</p>",
excerpt: "Brief description...",
featuredImage: "https://storage.com/image-en.jpg"
},
fr: {
body: "<p>Bienvenue dans notre entreprise...</p>",
excerpt: "Brève description...",
featuredImage: "https://storage.com/image-fr.jpg"
}
}

SEO and additional metadata follow a standardized format:

metadata: {
seo: {
title: {
en: "Custom SEO Title",
fr: "Titre SEO personnalisé"
},
description: {
en: "Meta description for search engines",
fr: "Description méta pour les moteurs de recherche"
},
keywords: ["keyword1", "keyword2"],
ogImage: "https://storage.com/og-image.jpg"
},
custom: {
// Entity-specific custom fields
featured: true,
category: "technology",
priority: 1
}
}
graph LR
A[Draft] --> B[Review]
B --> C[Published]
B --> A
C --> B
C --> D[Archived]
  • Content is being created or edited
  • Not visible on the frontend
  • Can be saved and resumed later
  • Ready for editorial review
  • Can be previewed with special URLs
  • Awaiting approval or revision
  • Live on the frontend website
  • Available via public APIs
  • Cached for performance
  • No longer active but preserved
  • Not visible on frontend
  • Accessible in CMS for reference

Every entity maintains a revision history:

model EntityRevision {
id String @id @default(cuid())
entityId String
version Int
data Json // Full entity data snapshot
changedBy String
changeLog String? // Summary of changes
createdAt DateTime @default(now())
@@unique([entityId, version])
}

Each entity type has its own tRPC router:

export const appRouter = router({
// Entity routers
users: usersRouter,
pages: pagesRouter,
collections: collectionsRouter,
globals: globalsRouter,
// Utility routers
media: mediaRouter,
search: searchRouter,
analytics: analyticsRouter,
});

All entity routers provide standard CRUD operations:

// Example: Pages router
export const pagesRouter = router({
// Read operations
getAll: publicProcedure.query(async () => { ... }),
getById: publicProcedure.input(z.string()).query(async ({ input }) => { ... }),
getBySlug: publicProcedure.input(z.string()).query(async ({ input }) => { ... }),
getPublished: publicProcedure.query(async () => { ... }),
// Write operations (protected)
create: protectedProcedure.input(createPageSchema).mutation(async ({ input }) => { ... }),
update: protectedProcedure.input(updatePageSchema).mutation(async ({ input }) => { ... }),
delete: protectedProcedure.input(z.string()).mutation(async ({ input }) => { ... }),
// Status operations
publish: protectedProcedure.input(z.string()).mutation(async ({ input }) => { ... }),
unpublish: protectedProcedure.input(z.string()).mutation(async ({ input }) => { ... }),
// Bulk operations
bulkUpdate: adminProcedure.input(bulkUpdateSchema).mutation(async ({ input }) => { ... }),
bulkDelete: adminProcedure.input(z.array(z.string())).mutation(async ({ input }) => { ... }),
});

Each entity uses Zod schemas for validation:

// Base schema for all entities
export const baseEntitySchema = z.object({
id: z.string().cuid().optional(),
slug: z.string().min(1).max(100),
title: localizedStringSchema,
content: localizedRichContentSchema,
metadata: entityMetadataSchema.optional(),
published: z.boolean().default(false),
publishedAt: z.date().optional(),
});
// Localized string schema
export const localizedStringSchema = z.record(
z.enum(['en', 'fr', 'es']), // Supported locales
z.string().min(1)
);
// Rich content schema with images and formatting
export const localizedRichContentSchema = z.record(
z.enum(['en', 'fr', 'es']),
z.object({
body: z.string(),
excerpt: z.string().optional(),
featuredImage: z.string().url().optional(),
gallery: z.array(z.string().url()).optional(),
})
);

Generated TypeScript types ensure type safety:

// Generated from Prisma schema
import type { Page, Collection, Global, User } from '@prisma/client';
// Custom types for API responses
export type LocalizedPage = Page & {
title: Record<Locale, string>;
content: Record<Locale, RichContent>;
};
export type PageWithAuthor = Page & {
author: Pick<User, 'id' | 'name' | 'email'>;
};
export type PublishedPage = Pick<Page, 'id' | 'slug' | 'title' | 'content' | 'publishedAt'>;

Each entity operation respects user roles:

// Permission matrix
const permissions = {
users: {
read: ['admin', 'editor', 'viewer'],
create: ['admin'],
update: ['admin'],
delete: ['admin']
},
pages: {
read: ['admin', 'editor', 'viewer'],
create: ['admin', 'editor'],
update: ['admin', 'editor'],
delete: ['admin']
},
collections: {
read: ['admin', 'editor', 'viewer'],
create: ['admin', 'editor'],
update: ['admin', 'editor'],
delete: ['admin', 'editor']
},
globals: {
read: ['admin', 'editor', 'viewer'],
create: ['admin'],
update: ['admin', 'editor'],
delete: ['admin']
}
};

Editors can only modify content they created:

// Check ownership or admin role
async function canModifyEntity(userId: string, entityId: string, userRole: string) {
if (userRole === 'admin') return true;
const entity = await prisma.page.findUnique({
where: { id: entityId },
select: { authorId: true }
});
return entity?.authorId === userId;
}

Strategic indexes improve query performance:

-- Common query patterns
CREATE INDEX idx_pages_published ON pages(published);
CREATE INDEX idx_pages_slug ON pages(slug);
CREATE INDEX idx_pages_created_at ON pages(created_at);
CREATE INDEX idx_pages_author_published ON pages(author_id, published);
-- Full-text search
CREATE INDEX idx_pages_search ON pages USING gin(to_tsvector('english', title || ' ' || content));

API responses are cached based on entity type:

// Cache configuration
const cacheConfig = {
pages: {
ttl: 3600, // 1 hour for static pages
tags: ['pages']
},
collections: {
ttl: 1800, // 30 minutes for dynamic content
tags: ['collections']
},
globals: {
ttl: 86400, // 24 hours for site settings
tags: ['globals']
}
};

Explore each entity type in detail:

  1. Users - User management and roles
  2. Pages - Static page creation and management
  3. Collections - Dynamic content types
  4. Globals - Site-wide settings and content

Each section provides specific implementation details, best practices, and code examples for working with that entity type.