Content Management

Guide for managing content using Payload CMS and Builder.io in ShipKit

Content Management

This guide covers content management in ShipKit using both Payload CMS for structured content and Builder.io for visual editing.

Payload CMS Setup

Configuration

// src/payload.config.ts
import { buildConfig } from "payload/config";
import path from "path";
import { Users } from "./collections/Users";
import { Pages } from "./collections/Pages";
import { Posts } from "./collections/Posts";

export default buildConfig({
  serverURL: process.env.NEXT_PUBLIC_SERVER_URL,
  admin: {
    user: Users.slug,
    meta: {
      titleSuffix: "- ShipKit Admin",
      favicon: "/favicon.ico",
      ogImage: "/og-image.jpg",
    },
  },
  collections: [Users, Pages, Posts],
  typescript: {
    outputFile: path.resolve(__dirname, "payload-types.ts"),
  },
  graphQL: {
    schemaOutputFile: path.resolve(__dirname, "generated-schema.graphql"),
  },
});

Collection Types

// src/collections/Pages.ts
import { CollectionConfig } from "payload/types";

export const Pages: CollectionConfig = {
  slug: "pages",
  admin: {
    useAsTitle: "title",
    defaultColumns: ["title", "status", "updatedAt"],
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: "title",
      type: "text",
      required: true,
    },
    {
      name: "slug",
      type: "text",
      required: true,
      unique: true,
      admin: {
        position: "sidebar",
      },
    },
    {
      name: "content",
      type: "richText",
      required: true,
    },
    {
      name: "status",
      type: "select",
      options: [
        {
          label: "Draft",
          value: "draft",
        },
        {
          label: "Published",
          value: "published",
        },
      ],
      defaultValue: "draft",
      admin: {
        position: "sidebar",
      },
    },
    {
      name: "meta",
      type: "group",
      fields: [
        {
          name: "title",
          type: "text",
        },
        {
          name: "description",
          type: "textarea",
        },
        {
          name: "image",
          type: "upload",
          relationTo: "media",
        },
      ],
    },
  ],
};

Builder.io Integration

Component Registration

// src/lib/builder/components.ts
import { Builder } from "@builder.io/react";
import { Hero } from "@/components/hero";
import { Features } from "@/components/features";
import { Testimonials } from "@/components/testimonials";

// Register custom components
Builder.registerComponent(Hero, {
  name: "Hero",
  inputs: [
    {
      name: "title",
      type: "string",
      required: true,
    },
    {
      name: "subtitle",
      type: "string",
    },
    {
      name: "image",
      type: "file",
      allowedFileTypes: ["jpeg", "jpg", "png", "webp"],
      required: true,
    },
    {
      name: "cta",
      type: "object",
      subFields: [
        {
          name: "text",
          type: "string",
          defaultValue: "Learn More",
        },
        {
          name: "link",
          type: "string",
          defaultValue: "#",
        },
      ],
    },
  ],
});

// Register more components...

Content Models

// src/lib/builder/models.ts
import { Builder } from "@builder.io/react";

// Define page model
Builder.defineCustomModel("page", {
  name: "Page",
  fields: [
    {
      name: "title",
      type: "string",
      required: true,
    },
    {
      name: "url",
      type: "string",
      required: true,
      helperText: "The URL path for this page (e.g., /about)",
    },
    {
      name: "meta",
      type: "object",
      subFields: [
        {
          name: "title",
          type: "string",
        },
        {
          name: "description",
          type: "string",
        },
        {
          name: "image",
          type: "file",
        },
      ],
    },
  ],
});

// Define section model
Builder.defineCustomModel("section", {
  name: "Section",
  fields: [
    {
      name: "name",
      type: "string",
      required: true,
    },
    {
      name: "layout",
      type: "enum",
      options: ["full", "contained", "split"],
      defaultValue: "contained",
    },
    {
      name: "background",
      type: "object",
      subFields: [
        {
          name: "color",
          type: "color",
        },
        {
          name: "image",
          type: "file",
        },
      ],
    },
  ],
});

Content Delivery

Server Components

// src/components/content/page-content.tsx
import { builder } from "@builder.io/react";
import { RenderBuilderContent } from "@/components/builder/render-content";
import { notFound } from "next/navigation";

interface PageContentProps {
  url: string;
}

export async function PageContent({ url }: PageContentProps) {
  const content = await builder
    .get("page", {
      url,
      options: {
        includeRefs: true,
      },
    })
    .promise();

  if (!content) {
    return notFound();
  }

  return <RenderBuilderContent content={content} />;
}

Client Components

// src/components/content/editable-content.tsx
"use client";

import { Builder, BuilderComponent } from "@builder.io/react";
import { useRouter } from "next/navigation";

interface EditableContentProps {
  content: any;
  model: string;
}

export function EditableContent({ content, model }: EditableContentProps) {
  const router = useRouter();

  return (
    <BuilderComponent
      model={model}
      content={content}
      data={{
        router,
      }}
    />
  );
}

Content Management

Content Services

// src/lib/content/service.ts
import { builder } from "@builder.io/react";
import { db } from "@/lib/db";
import { pages } from "@/lib/db/schema";
import { eq } from "drizzle-orm";

export class ContentService {
  static async getPage(slug: string) {
    // Try Builder.io first
    const builderContent = await builder
      .get("page", {
        url: slug,
      })
      .promise();

    if (builderContent) {
      return {
        type: "builder",
        content: builderContent,
      };
    }

    // Fall back to Payload CMS
    const payloadContent = await db.query.pages.findFirst({
      where: eq(pages.slug, slug),
    });

    if (payloadContent) {
      return {
        type: "payload",
        content: payloadContent,
      };
    }

    return null;
  }

  static async createPage(data: any) {
    const { type, content } = data;

    if (type === "builder") {
      return await builder.create("page", {
        data: content,
      });
    }

    // Create in Payload CMS
    return await db.insert(pages).values(content);
  }

  static async updatePage(id: string, data: any) {
    const { type, content } = data;

    if (type === "builder") {
      return await builder.update({
        model: "page",
        id,
        data: content,
      });
    }

    // Update in Payload CMS
    return await db
      .update(pages)
      .set(content)
      .where(eq(pages.id, id));
  }
}

Preview Mode

Preview Configuration

// src/app/api/preview/route.ts
import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get("secret");
  const slug = searchParams.get("slug");
  const type = searchParams.get("type") || "builder";

  if (secret !== process.env.PREVIEW_SECRET) {
    return new Response("Invalid token", { status: 401 });
  }

  // Enable preview mode
  cookies().set(`preview_${type}`, "true", {
    path: "/",
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
  });

  redirect(slug ?? "/");
}

Content Types

Rich Text Editor

// src/components/content/rich-text.tsx
import { Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Toolbar } from "./toolbar";

interface RichTextEditorProps {
  value: string;
  onChange: (value: string) => void;
}

export function RichTextEditor({
  value,
  onChange,
}: RichTextEditorProps) {
  const editor = useEditor({
    extensions: [StarterKit],
    content: value,
    onUpdate: ({ editor }) => {
      onChange(editor.getHTML());
    },
  });

  return (
    <div className="border rounded-lg">
      <Toolbar editor={editor} />
      <EditorContent editor={editor} />
    </div>
  );
}

Best Practices

Content Structure

  1. Content Types

    • Define clear content models
    • Use appropriate field types
    • Implement validation rules
  2. Content Organization

    • Use logical hierarchies
    • Implement proper taxonomies
    • Consider content relationships
  3. Content Workflow

    • Define clear publishing workflows
    • Implement content versioning
    • Set up content approval processes

Performance

  1. Caching

    • Implement proper caching strategies
    • Use CDN for media assets
    • Cache API responses
  2. Optimization

    • Optimize media assets
    • Use lazy loading
    • Implement proper pagination
  3. SEO

    • Implement proper meta tags
    • Use semantic HTML
    • Add structured data

Examples

Content Page

// src/app/[...slug]/page.tsx
import { ContentService } from "@/lib/content/service";
import { PageContent } from "@/components/content/page-content";
import { RichContent } from "@/components/content/rich-content";

interface PageProps {
  params: {
    slug: string[];
  };
}

export default async function Page({ params }: PageProps) {
  const slug = "/" + params.slug.join("/");
  const content = await ContentService.getPage(slug);

  if (!content) {
    return notFound();
  }

  if (content.type === "builder") {
    return <PageContent content={content.content} />;
  }

  return <RichContent content={content.content} />;
}

export async function generateStaticParams() {
  // Generate static paths for both Builder.io and Payload CMS content
  const pages = await Promise.all([
    builder.getAll("page", {
      fields: "data.url",
      options: { noTargeting: true },
    }),
    db.query.pages.findMany({
      columns: { slug: true },
    }),
  ]);

  return [
    ...pages[0].map((page) => ({
      slug: page.data?.url?.split("/").filter(Boolean) || [],
    })),
    ...pages[1].map((page) => ({
      slug: page.slug.split("/").filter(Boolean),
    })),
  ];
}

Related Resources