Create your own content management system with Remix and Xata
Tags: ai, database, tutorial
Published: 2024-06-07 14:37:44

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

## Before you begin

### Prerequisites

You'll need the following:

- A [Xata](https://xata.io/) account
- [Node.js 18](https://nodejs.org/en/blog/announcements/v18-release-announce) or later
- An [OpenAI](https://platform.openai.com) account
- A [Vercel](https://vercel.com) Account

### Tech Stack

| Technology                                    | Description                                                                     |
| --------------------------------------------- | ------------------------------------------------------------------------------- |
| [Xata](https://xata.io)                       | Serverless database platform for scalable, real-time applications.              |
| [Remix](https://remix.run)                    | Framework for building full-stack web applications with focus on Web Standards. |
| [litelln](https://github.com/BerriAI/litellm) | Call all LLM APIs using the OpenAI format.                                      |
| [Nove](https://novel.sh)                      | A Notion-style WYSIWYG editor with AI-powered autocompletion                    |
| [TailwindCSS](https://tailwindcss.com/)       | CSS framework for building custom designs                                       |
| [Vercel](https://vercel.com)                  | A cloud platform for deploying and scaling web applications.                    |

## Setting up a Xata Database

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

![Create a database](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/esaibyr2zuh9zn2hrnmd.png)

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

![Create uploads table](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n9o7t9n9cuwwit9t0x8h.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/69a7erzy6k9d6tagnm0f.png)

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

## Creating the Schema

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k7erl1ids1eyhz2kw20o.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/816p851prujdnvkiyzcq.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sxmwf3skyyn5y366mjcy.png)

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](https://xata.io/docs/rest-api/limits#column-limits).

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

![Add more CMS columns](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t1a8uyqla3sb07w9p7j2.png)

## Setting up the project

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

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

## Configure Xata with Remix

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

```bash
npm install @xata.io/cli -g
```

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

```bash
xata auth login
```

![Create new API key](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3py0duhg6qb4gvtme4yv.png)

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.

```bash
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jv1mgyhybu1gpankmrap.png)

## Implementing form actions in Remix

With Remix, [Route Actions](https://reactrouter.com/en/main/route/action) 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.

```tsx
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`).

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

## Handling Client Side Image Uploads with Xata

To let user add their own custom OG Image with the content, we use Xata [Upload URLs](https://xata.io/docs/sdk/file-attachments#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**.

```tsx
// 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 });
}
```

2.  Do a client side `PUT` request to the **uploadUrl** with body as image’s buffer.

```tsx
// 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);
};
```

## Using an AI powered WYSIWYG Editor

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.

```tsx
// 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>
  );
}
```

## Implementing autocompletion using LiteLLM

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](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).

```tsx
// 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'
    }
  });
}
```

## **Implementing Content Wide Search with Xata Search**

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`.

```tsx
// 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 });
}
```

## Creating Dynamic Routes in Remix

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.

```tsx
// 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>
  );
}
```

## Deploy to Vercel

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! 🚀

## More Information

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

| Resource              | Link                                                                    |
| --------------------- | ----------------------------------------------------------------------- |
| GitHub Repo           | https://github.com/rishi-raj-jain/remix-wysiwyg-litellm-xata-vercel     |
| Remix with Xata       | https://xata.io/docs/getting-started/remix                              |
| Xata File Attachments | https://xata.io/docs/sdk/file-attachments#upload-a-file-using-file-apis |
| Remix Route Actions   | https://remix.run/docs/en/main/discussion/data-flow#route-action        |
| Remix Route Loaders   | https://remix.run/docs/en/main/discussion/data-flow#route-loader        |

## What’s next?

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](https://discord.com/invite/kvAcQKh7vm) or join us on [X | Twitter](https://twitter.com/xata). Happy building 🦋