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:
You'll need the following:
Technology | Description |
---|---|
Xata | Serverless database platform for scalable, real-time applications. |
Remix | Framework for building full-stack web applications with focus on Web Standards. |
LiteLLM | Call all LLM APIs using the OpenAI format. |
Novel | 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. |
After you've created a Xata account and are logged in, create a database.
The next step is to create a table, in this instance uploads
, that contains all the uploaded images.
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.
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.
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.
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.
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 👇🏻
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
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.
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 →</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:
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 });
}
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 →</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: 👇🏻
.env
file.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 |
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 🦋
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.
Copyright © 2024 Xatabase Inc.
All rights reserved.