Create your own content management system with Remix and Xata

cezz

Cezzaine Zaher

Posted on June 7, 2024

Create your own content management system with Remix and Xata

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:

Tech Stack

Technology Description
Xata Serverless database platform for scalable, real-time applications.
Remix Framework for building full-stack web applications with focus on Web Standards.
litelln Call all LLM APIs using the OpenAI format.
Nove A Notion-style WYSIWYG editor with AI-powered autocompletion
TailwindCSS CSS framework for building custom designs
Vercel 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

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

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

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

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

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

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

Setting up the project

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
Enter fullscreen mode Exit fullscreen mode

Configure Xata with Remix

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

npm install @xata.io/cli -g
Enter fullscreen mode Exit fullscreen mode

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

xata auth login
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Xata CLI

Implementing form actions in Remix

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Handling Client Side Image Uploads with Xata

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 });
}
Enter fullscreen mode Exit fullscreen mode
  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);
};
Enter fullscreen mode Exit fullscreen mode

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.

// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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'
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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 });
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

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 or join us on X | Twitter. Happy building 🦋

💖 💪 🙅 🚩
cezz
Cezzaine Zaher

Posted on June 7, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related