Create your own content management system with Remix and Xata

Learn how to create a custom CMS using Xata, Remix, Novel, LiteLLM, and Vercel.

Written by

Rishi Raj Jain

Published on

February 7, 2024

In this post, you'll create a content CMS using Xata, Remix, Novel, LiteLLM, and Vercel. You'll learn how to:

  • Set up Xata
  • Create a schema with different column types
  • Handle forms in Remix using Form Actions
  • Implement Client Side Image Uploads
  • Use an AI-powered WYSIWYG Editor
  • Implement content-wide search
  • Create dynamic content routes with Remix

You'll need the following:

TechnologyDescription
XataServerless database platform for scalable, real-time applications.
RemixFramework for building full-stack web applications with focus on Web Standards.
LiteLLMCall all LLM APIs using the OpenAI format.
NovelA Notion-style WYSIWYG editor with AI-powered autocompletion
TailwindCSSCSS framework for building custom designs
VercelA cloud platform for deploying and scaling web applications.

After you've created a Xata account and are logged in, create a database.

Create a database
Create a database

The next step is to create a table, in this instance uploads, that contains all the uploaded images.

Create uploads table
Create uploads table

Great, now click on Schema in the left sidebar and create one more table content. You can do this by clicking Add a table. The tables will contain user content and user uploaded photographs. With that completed, you will see the schema as below.

View uploads and content schema
View uploads and content schema

Let’s move on to adding relevant columns in the tables you've just created.

In the uploads table, you want to store all the images only (and no other attributes) so that you can create references to the same image object again, if needed.

Proceed with adding the column named image. This column is responsible for storing the file type objects. In our case, the file type object is for images, but you can use this for storing any kind of blob (e.g. PDF, fonts, etc.) that’s sized up to 1 GB.

First, click + Add column and select File.

Add a column
Add a column

Set the column name to photo and to make files public (so that they can be shown to users when they visit the image gallery), check the Make files public by default option.

Make files public by default
Make files public by default

In the content table, we want to store the attributes such as content’s unique slug (the path of the url where content will be displayed), title, author name, author’s image with it’s dimensions, and content’s og image with it’s dimensions.

Proceed with adding the column named slug. It is responsible for maintaining the uniqueness of each content that gets created. Click + Add a column, select String type and enter the column name as slug. To associate a slug with only one content, check the Unique attribute to make sure that duplicate entries do not get inserted.

Add slug column
Add slug column

In similar fashion, create title, author_name, author_image_url, og_image_url, author_image_w, author_image_h, og_image_w, og_image_h as String type (but not Unique).

Great, you can also store the user content as Text type. While String is a great default type, storing more than 2048 characters would require you to switch to the Text type. Read more about the limits in Xata Column limits.

Lovely! With all that done, the final schema shall be as below 👇🏻

Add more CMS columns
Add more CMS columns

Clone the app repository and follow this tutorial; you can fork the project by running the following command:

git clone https://github.com/rishi-raj-jain/remix-wysiwyg-litellm-xata-vercel
cd remix-wysiwyg-litellm-xata-vercel
npm install

To seamlessly use Xata with Remix, install the Xata CLI globally:

npm install @xata.io/cli -g

Then, authorize the Xata CLI so it is associated with the logged in account:

xata auth login
Create new API key
Create new API key

Great! Now, initialize your project locally with the Xata CLI command. In this command, you will need to use the database URL for the database that you just created. You can copy the URL from the Settings page of the database.

xata init --db https://Rishi-Raj-Jain-s-workspace-80514q.us-east-1.xata.sh/db/remix-wysiwyg-litellm-xata-vercel

Answer some quick one-time questions from the CLI to integrate with Remix.

Xata CLI
Xata CLI

With Remix, Route Actions are the way to process form POST request(s). Here’s how we’ve enabled form actions to process the form submissions and insert records into the Xata database.

import { Form } from '@remix-run/react'
import { ActionFunctionArgs, json, redirect } from '@remix-run/node'
 
export async function action({ request }: ActionFunctionArgs) {
	// Get the form data
	const body = await request.formData()
}
 
export default function Index() {
  return (
    <Form navigate={false} method="post" className="mt-8 flex flex-col">
      {% my form elements %}
    </Form>
  )
}

This allows you to colocate the serverless backend and frontend flow for a given page in Remix. Say, you accept a form submission containing the title, slug, and the content’s HTML, process it on the server, and sync it with your Xata serverless database. Here’s how you’d do all of that in a single Remix route (app/routes/_index.tsx).

// app/routes/_index.tsx
 
import { Editor } from 'novel';
import { Form } from '@remix-run/react';
import { getXataClient } from '@/xata.server';
import Upload from '@/components/Utility/Upload';
import { ActionFunctionArgs, json, redirect } from '@remix-run/node';
 
export async function action({ request }: ActionFunctionArgs) {
  // Import the Xata Client created by the Xata CLI in app/xata.server.ts
  const xata = getXataClient();
  // Get the form data
  const body = await request.formData();
  const slug = body.get('slug') as string;
  const title = body.get('title') as string;
  const content = body.get('content-html') as string;
  // Sync the attributes to the content table in Xata
  await xata.db.content.create({ slug, title, content });
}
 
export default function Index() {
  return (
    <Form navigate={false} method="post">
      <span>New Article</span>
      <span>Title</span>
      <input required autoComplete="off" id="title" name="title" placeholder="Title" />
      <span>Content</span>
      <input required id="content-html" name="content-html" />
      <span>Slug</span>
      <input required autoComplete="off" id="slug" name="slug" placeholder="Slug" />
      <button type="submit">Publish &rarr;</button>
    </Form>
  );
}

To let user add their own custom OG Image with the content, we use Xata Upload URLs to handle image uploads on the client side. There are 2 steps to make a successful client side image upload with Xata and Remix:

  1. Create a record with empty photo base64Content and obtain the photo’s uploadUrl.
// app/routes/api_.image.upload.tsx
 
import { json } from '@remix-run/node';
import { getXataClient } from '@/xata.server';
 
export async function loader() {
  const xata = getXataClient();
  // Use the Xata client to create a new 'photo' record with an empty base64 content
  const result = await xata.db.uploads.create({ photo: { base64Content: '' } }, ['photo.uploadUrl']);
  return json({ uploadUrl: result?.photo?.uploadUrl });
}
  1. Do a client side PUT request to the uploadUrl with body as image’s buffer.
// app/components/Utility/Upload.tsx
 
const uploadFile = (e: ChangeEvent<HTMLInputElement>) => {
  // Get the reference to the file uploaded
  const file = e.target.files?.[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = async (event) => {
    // Load the file buffer
    const fileData = event.target?.result;
    if (fileData) {
      // Create blob from the file data with the relevant file's type
      const body = new Blob([fileData], { type: file.type });
      // Make a fetch to the get the uploadUrl
      fetch('/api/image/upload')
        .then((res) => res.json())
        .then((res) => {
          // Use the uploadUrl to upload the buffer
          fetch(res.uploadUrl, {
            body,
            method: 'PUT'
          });
        });
    }
  };
  // Read the user uploaded file as buffer
  reader.readAsArrayBuffer(file);
};

For making it easier to write content, users need a reliable and user-friendly AI powered WYSIWYG editor. We’re using Novel, a Notion-Style WYSIWYG Editor providing a seamless experience with intuitive features and real-time preview of the content being written. To get the content being written as HTML, we use Novel’s onUpdate callback and set the HTML string to an input inside the form element.

// app/routes/_index.tsx
 
import { Editor } from 'novel';
import { Form } from '@remix-run/react';
 
export default function Index() {
  return (
    <Form navigate={false} method="post">
      <span>Content</span>
      <input required id="content-html" name="content-html" />
      <Editor
        defaultValue={{}}
        storageKey="novel__editor"
        onUpdate={(e) => {
          if (!e) return;
          const tmp = e.getHTML();
          const htmlSelector = document.getElementById('content-html');
          if (tmp && htmlSelector) htmlSelector.setAttribute('value', tmp);
        }}
      />
      <button type="submit">Publish &rarr;</button>
    </Form>
  );
}

Under the hood, Novel makes a POST request to /api/generate expecting a stream of tokens from OpenAI API. Well, let’s see how we’ve customised the endpoint to get the flexibility of using any AI API provider with LiteLLM. With LiteLLM, you can call 100+ LLMs with the same OpenAI-like input and output. To implement autocompletion with streaming, we use the completion method with stream flag set to true and further return the response obtained as a ReadableStream.

// app/routes/api_.generate.tsx
 
import { completion } from 'litellm';
import { ActionFunctionArgs } from '@remix-run/node';
 
export async function action({ request }: ActionFunctionArgs) {
  const encoder = new TextEncoder();
  const { prompt } = await request.json();
  const response = await completion({
    n: 1,
    top_p: 1,
    stream: true,
    temperature: 0.7,
    presence_penalty: 0,
    model: 'gpt-3.5-turbo',
    messages: [
      {
        role: 'system',
        content:
          'You are an AI writing assistant that continues existing text based on context from prior text. ' +
          'Give more weight/priority to the later characters than the beginning ones. ' +
          'Limit your response to no more than 200 characters, but make sure to construct complete sentences.'
        // we're disabling markdown for now until we can figure out a way to stream markdown text with proper formatting: https://github.com/steven-tey/novel/discussions/7
        // "Use Markdown formatting when appropriate.",
      },
      {
        role: 'user',
        content: prompt
      }
    ]
  });
  // Create a streaming response
  const customReadable = new ReadableStream({
    async start(controller) {
      for await (const part of response) {
        try {
          const tmp = part.choices[0]?.delta?.content;
          if (tmp) controller.enqueue(encoder.encode(tmp));
        } catch (e) {
          console.log(e);
        }
      }
      controller.close();
    }
  });
  // Return the stream response and keep the connection alive
  return new Response(customReadable, {
    // Set the headers for Server-Sent Events (SSE)
    headers: {
      Connection: 'keep-alive',
      'Content-Encoding': 'none',
      'Cache-Control': 'no-cache, no-transform',
      'Content-Type': 'text/event-stream; charset=utf-8'
    }
  });
}

To let user search through the entire collection of the content, we use Remix Route Actions with Xata Search to retrieve relevant records from the database. With Xata Search, you can choose the tables to search through, in this instance, content and set the targets to search on, in this instance, title, slug, content and author_name.

// app/routes/_index.tsx
 
export async function action({ request }: ActionFunctionArgs) {
  const body = await request.formData();
  const search = body.get('search') as string;
  // If the 'search' parameter is missing, redirect to '/content'
  if (!search) return redirect('/content');
  const xata = getXataClient();
 
  // Use the Xata client to perform a search across specified tables with fuzziness
  const { records } = await xata.search.all(search, {
    tables: [
      {
        table: 'content',
        target: ['content', 'title', 'slug', 'author_name']
      }
    ],
    fuzziness: 2
  });
 
  // Extract the 'record' property from each search result containing the content
  const result = records.map((i) => i.record);
  return json({ search, result });
}

To create a page dynamically for each content, we're gonna use Remix Dynamic Routes and Route Loaders. Creating a page with $ in it, in this instance, content_.$id.tsx specifies a dynamic route where each part of the URL for e.g. for /content/a, /content/b or /content/anything captures the last segment into the id param.

With Remix Loader and Xata Records, we dynamically query the database to give us the content pertaining to a particular id. Once obtained, we process and return the content as HTML string. Finally, we use the loader data to prototype the UI with best practices such as lazy loading non-critical images.

// app/routes/content_.$id.tsx
 
import { getXataClient } from '@/xata.server';
import Image from '@/components/Utility/Image';
import { useLoaderData } from '@remix-run/react';
import { unescapeHTML } from '@/lib/util.server';
import { getTransformedImage } from '@/lib/ast.server';
import { LoaderFunctionArgs, redirect } from '@remix-run/node';
 
export async function loader({ params }: LoaderFunctionArgs) {
  if (!params.id) return redirect('/404');
  const xata = getXataClient();
  // Use the Xata client to fetch content from the 'content' table based on the 'slug'
  const content = await xata.db.content
    .filter({
      slug: params.id
    })
    .getFirst();
  if (content) {
    const output = await getTransformedImage(content);
    return { ...content, content: unescapeHTML(output) };
  }
  // If content is not found, redirect to '/404'
  return redirect('/404');
}
 
export default function Pic() {
  const content = useLoaderData<typeof loader>();
  return (
    <div>
      <span>{content.title}</span>
      <div>
        <Image
          alt={content.author_name}
          url={content.author_image_url}
          width={content.author_image_w}
          height={content.author_image_h}
        />
        <div>
          <span>{content.author_name}</span>
        </div>
      </div>
      <Image
        loading="eager"
        alt={content.title}
        url={content.og_image_url}
        width={content.og_image_w}
        height={content.og_image_h}
      />
      {content.content && <div dangerouslySetInnerHTML={{ __html: content.content }} />}
    </div>
  );
}

The repository, is now ready to deploy to Vercel. Use the following steps to deploy: 👇🏻

  • Start by creating a GitHub repository containing your app's code.
  • Then, navigate to the Vercel Dashboard and create a New Project.
  • Link the new project to the GitHub repository you just created.
  • In Settings, update the Environment Variables to match those in your local .env file.
  • Deploy! 🚀

For more detailed insights, explore the references cited in this post.

ResourceLink
GitHub Repohttps://github.com/rishi-raj-jain/remix-wysiwyg-litellm-xata-vercel
Remix with Xatahttps://xata.io/docs/getting-started/remix
Xata File Attachmentshttps://xata.io/docs/sdk/file-attachments#upload-a-file-using-file-apis
Remix Route Actionshttps://remix.run/docs/en/main/discussion/data-flow#route-action
Remix Route Loadershttps://remix.run/docs/en/main/discussion/data-flow#route-loader

We'd love to hear from you if you have any feedback on this tutorial, would like to know more about Xata, or if you'd like to contribute a community blog or tutorial. Reach out to us on Discord or join us on X | Twitter. Happy building 🦋

Start free, pay as you grow

Xata provides the best free plan in the industry. It is production ready by default and doesn't pause or cool-down. Take your time to build your business and upgrade when you're ready to scale.

Free plan includes
  • 10 database branches
  • High availability
  • Support-assisted daily backups
  • 15 GB data storage
  • 15 GB search engine storage
  • 2 GB file attachments
  • 250 AI queries per month
Free plan includes
  • 10 database branches
  • High availability
  • Support-assisted daily backups
  • 15 GB data storage
  • 15 GB search engine storage
  • 2 GB file attachments
  • 250 AI queries per month