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 account
- Node.js 18 or later
- An OpenAI account
- A Vercel Account
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.
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.
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.
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 👇🏻
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
Configure Xata with Remix
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.
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>
)
}
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>
);
}
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:
- 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 });
}
- 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);
};
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 →</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.
// 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
.
// 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.
// 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 or join us on X | Twitter. Happy building 🦋
Top comments (0)