Content Management

Managing content with Payload CMS and Builder.io in ShipKit

Content Management

ShipKit provides a powerful content management system combining Payload CMS for structured content and Builder.io for visual editing. This hybrid approach gives you the best of both worlds.

Payload CMS Integration

Setup

// src/payload.config.ts
import path from "path";
import { fileURLToPath } from "url";
import { postgresAdapter } from "@payloadcms/db-postgres";
import { payloadCloudPlugin } from "@payloadcms/payload-cloud";
import { lexicalEditor } from "@payloadcms/richtext-lexical";
import { buildConfig } from "payload";
import sharp from "sharp";

import { FAQs } from "./collections/FAQs";
import { Features } from "./collections/Features";
import { Media } from "./collections/Media";
import { RBAC } from "./collections/RBAC";
import { Testimonials } from "./collections/Testimonials";
import { Users } from "./collections/Users";

export default buildConfig({
  secret: process.env.PAYLOAD_SECRET ?? "",
  routes: {
    admin: "/cms",
    api: "/cms-api",
  },
  admin: {
    user: Users.slug,
    importMap: {
      baseDir: path.resolve(dirname),
    },
  },
  collections: [Users, Media, Features, FAQs, Testimonials, RBAC],
  editor: lexicalEditor(),
  typescript: {
    outputFile: path.resolve(dirname, "payload-types.ts"),
  },
  db: postgresAdapter({
    schemaName: "payload",
    pool: {
      connectionString: process.env.DATABASE_URL ?? "",
    },
  }),
  sharp,
  plugins: [
    payloadCloudPlugin(),
  ],
  telemetry: false,
});

Collections

ShipKit comes with several pre-configured collections:

Users Collection

// src/collections/Users.ts
import type { CollectionConfig } from "payload";

export const Users: CollectionConfig = {
  slug: "users",
  admin: {
    useAsTitle: "email",
  },
  auth: true,
  fields: [
    // Email added by default
    // Add more fields as needed
  ],
};

Features Collection

// src/collections/Features.ts
import type { CollectionConfig } from "payload";

export const Features: CollectionConfig = {
  slug: "features",
  admin: {
    useAsTitle: "name",
    defaultColumns: ["name", "category", "plans"],
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: "name",
      type: "text",
      required: true,
    },
    {
      name: "description",
      type: "textarea",
      required: true,
    },
    {
      name: "category",
      type: "select",
      required: true,
      options: [
        { label: "Core Features", value: "core" },
        { label: "Developer Experience", value: "dx" },
        { label: "Database & Backend", value: "backend" },
        { label: "Advanced Features", value: "advanced" },
        { label: "Security & Performance", value: "security" },
        { label: "Deployment & DevOps", value: "devops" },
        { label: "Support", value: "support" },
      ],
    },
    {
      name: "plans",
      type: "select",
      required: true,
      hasMany: true,
      options: [
        { label: "Bones", value: "bones" },
        { label: "Muscles", value: "muscles" },
        { label: "Brains", value: "brains" },
      ],
    },
    {
      name: "badge",
      type: "select",
      options: [
        { label: "New", value: "new" },
        { label: "Popular", value: "popular" },
        { label: "Pro", value: "pro" },
      ],
    },
    {
      name: "icon",
      type: "text",
      admin: {
        description: "Lucide icon name",
      },
    },
    {
      name: "order",
      type: "number",
      admin: {
        description: "Order within category",
      },
    },
  ],
};

FAQs Collection

// src/collections/FAQs.ts
import type { CollectionConfig } from "payload";

export const FAQs: CollectionConfig = {
  slug: "faqs",
  admin: {
    useAsTitle: "question",
    defaultColumns: ["question", "category"],
  },
  access: {
    read: () => true,
  },
  timestamps: true,
  fields: [
    {
      name: "question",
      type: "text",
      required: true,
    },
    {
      name: "answer",
      type: "richText",
      required: true,
    },
    {
      name: "category",
      type: "select",
      options: [
        { label: "General", value: "general" },
        { label: "Technical", value: "technical" },
        { label: "Pricing", value: "pricing" },
        { label: "Support", value: "support" },
      ],
    },
    {
      name: "order",
      type: "number",
    },
  ],
  indexes: [
    {
      name: "faqs_created_at_idx",
      fields: ["createdAt"],
    },
  ],
};

Media Collection

// src/collections/Media.ts
import type { CollectionConfig } from "payload";

export const Media: CollectionConfig = {
  slug: "media",
  access: {
    read: () => true,
  },
  fields: [
    {
      name: "alt",
      type: "text",
      required: true,
    },
  ],
  upload: true,
};

RBAC Collection

// src/collections/RBAC.ts
import type { CollectionConfig } from "payload";

export const RBAC: CollectionConfig = {
  slug: "rbac",
  admin: {
    useAsTitle: "name",
    defaultColumns: ["name", "type", "description"],
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: "name",
      type: "text",
      required: true,
    },
    {
      name: "description",
      type: "textarea",
      required: true,
    },
    {
      name: "type",
      type: "select",
      required: true,
      options: [
        { label: "Role", value: "role" },
        { label: "Permission", value: "permission" },
      ],
    },
    {
      name: "resource",
      type: "select",
      required: false,
      options: [
        { label: "Team", value: "team" },
        { label: "Project", value: "project" },
        { label: "User", value: "user" },
        { label: "API Key", value: "api_key" },
        { label: "Billing", value: "billing" },
        { label: "Settings", value: "settings" },
      ],
      admin: {
        condition: (data) => data.type === "permission",
      },
    },
    {
      name: "action",
      type: "select",
      required: false,
      options: [
        { label: "Create", value: "create" },
        { label: "Read", value: "read" },
        { label: "Update", value: "update" },
        { label: "Delete", value: "delete" },
        { label: "Manage", value: "manage" },
      ],
      admin: {
        condition: (data) => data.type === "permission",
      },
    },
    {
      name: "permissions",
      type: "relationship",
      relationTo: "rbac",
      hasMany: true,
      filterOptions: {
        type: {
          equals: "permission",
        },
      },
      admin: {
        condition: (data) => data.type === "role",
      },
    },
  ],
};

Testimonials Collection

// src/collections/Testimonials.ts
import type { CollectionConfig } from "payload";

export const Testimonials: CollectionConfig = {
  slug: "testimonials",
  admin: {
    useAsTitle: "name",
    defaultColumns: ["name", "company", "verified"],
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: "name",
      type: "text",
      required: true,
    },
    {
      name: "role",
      type: "text",
      required: true,
    },
    {
      name: "company",
      type: "text",
      required: true,
    },
    {
      name: "testimonial",
      type: "textarea",
      required: true,
    },
    {
      name: "username",
      type: "text",
    },
    {
      name: "verified",
      type: "checkbox",
      defaultValue: false,
    },
    {
      name: "featured",
      type: "checkbox",
      defaultValue: false,
    },
    {
      name: "image",
      type: "upload",
      relationTo: "media",
      required: false,
    },
  ],
};

Using Collections

Fetching Data

// src/app/features/page.tsx
import { db } from "@/server/db";
import { features } from "@/server/db/schema";

export default async function FeaturesPage() {
  const allFeatures = await db.query.features.findMany({
    orderBy: [
      { category: "asc" },
      { order: "asc" },
    ],
  });

  return (
    <div>
      {allFeatures.map((feature) => (
        <FeatureCard key={feature.id} feature={feature} />
      ))}
    </div>
  );
}

Creating Content

// src/server/actions/features.ts
"use server";

import { db } from "@/server/db";
import { features } from "@/server/db/schema";
import { revalidatePath } from "next/cache";

export async function createFeature(data: NewFeature) {
  const [feature] = await db
    .insert(features)
    .values(data)
    .returning();

  revalidatePath("/features");
  return feature;
}

Media Handling

Media uploads are handled through the Media collection with Sharp for image optimization:

// Example media upload configuration
export const Media: CollectionConfig = {
  slug: "media",
  upload: {
    staticDir: "public/uploads",
    imageSizes: [
      {
        name: "thumbnail",
        width: 400,
        height: 300,
        position: "centre",
      },
      {
        name: "card",
        width: 768,
        height: 1024,
        position: "centre",
      },
      {
        name: "feature",
        width: 1024,
        height: 576,
        position: "centre",
      },
    ],
  },
  // ... other configuration
};

Access Control

Access control is managed through the RBAC collection:

// Example access control in a collection
export const Features: CollectionConfig = {
  // ... other configuration
  access: {
    read: () => true,
    create: isAdmin,
    update: isAdmin,
    delete: isAdmin,
  },
};

// Access control helpers
const isAdmin = ({ req: { user } }) => {
  return user?.role === "admin";
};

Hooks and Lifecycle Events

// Example hooks in a collection
export const Features: CollectionConfig = {
  // ... other configuration
  hooks: {
    beforeChange: [
      ({ data }) => {
        // Normalize data before saving
        return {
          ...data,
          name: data.name.trim(),
        };
      },
    ],
    afterChange: [
      ({ doc }) => {
        // Revalidate cache after changes
        revalidatePath("/features");
      },
    ],
  },
};

Builder.io Integration

Builder.io integration documentation will be added in a future update. The integration is currently in development.