Create a headless CMS using OceanBase and TypeScript: A step-by-step tutorial

Wayne S.
OceanBase Database
Published in
16 min readDec 7, 2023
Image: unsplash

If you’re planning to start a blog or showcase your products on a website, you have two main options. You could code everything from scratch using HTML, CSS, and JavaScript, creating databases and interfaces to manage your content. This, however, can be challenging if you’re not a seasoned programmer. A more efficient alternative is to use a Content Management System (CMS), providing you with the tools to manage your content and design your website effortlessly.

There are numerous CMSs available, each with its strengths. WordPress, the most popular CMS, is known for its user-friendly interface and vast plugin ecosystem. Joomla and Drupal offer more robust platforms for complex websites, though they require some technical expertise. For beginners, Squarespace and Wix are ideal for creating visually attractive websites without needing to code.

However, these CMSs are often tied to the presentation layer, limiting your content’s reach to the platform they serve. If you want your content to reach audiences through various channels like mobile apps, smart devices, or voice assistants, traditional CMSs may fall short. The modern web extends beyond websites to include mobile and IoT devices, smartwatches, and in-car entertainment systems.

What is headless CMS?

Enter the world of headless CMS. A headless CMS takes a different approach, separating the content management from the content presentation. Imagine it as a conductor, orchestrating your content delivery to various platforms without being tied to a single instrument.

A headless CMS is a back-end content repository system that facilitates content storage, modification, and delivery via a RESTful API (Application Programming Interface) or GraphQL. It is called ‘headless’ because it decouples the ‘head’ (the front-end or the presentation layer) from the ‘body’ (the back-end or the content repository).

A headless CMS brings several benefits to the table. It gives developers the freedom to use their preferred tech stack for front-end development, fostering unique and tailored user experiences. It supports omnichannel content delivery, a critical factor in today’s multi-device world. Yet, it’s not without its challenges. The absence of a visual front-end may make it less intuitive for non-technical users.

It also demands a proficient development team to build the client side, and certain features like SEO and preview functionality may require extra effort to implement. Despite these challenges, a headless CMS, with its flexibility and adaptability, stands as a formidable tool in the right hands.

Building a headless CMS

Now that we’ve navigated the landscape of CMS, it’s time to get our hands dirty and dive into the heart of this article: building a headless CMS using TypeScript and OceanBase.

Why this particular tech stack, you might ask?

Let’s start with TypeScript. It’s a superset of JavaScript that introduces static typing, a feature that JavaScript traditionally lacks. Static typing enhances code reliability, predictability, and maintainability, making TypeScript a powerful part of our stack to build a robust headless CMS.

You might have come across blog posts hailing JavaScript as the “most popular back-end language for web development.” But in reality, pure JavaScript is seldom used for back-end development. More often than not, developers lean towards TypeScript, JavaScript’s statically typed sibling. The reason for this preference lies in TypeScript’s unique features that enhance code reliability and maintainability, making it a more suitable choice for back-end development.

Next up is OceanBase, my choice of database for most of my projects. As an enterprise-grade, high-performance relational database, OceanBase provides a strong backbone for the headless CMS. Its high scalability and reliability ensure that our content management system can handle vast amounts of data and traffic, making it a reliable choice for large-scale applications.

Finally, I’ve chosen Express.js to build the API server. Express is a flexible Node.js web application framework that provides a robust set of features for web and mobile applications. Its simplicity and flexibility make it an ideal choice for the CMS project, allowing us to build powerful APIs with minimal hassle.

The codebase of the project has been uploaded to this GitHub repo. You can clone and play around with it, or deploy it with GitPod and see it work in action.

Setting up the project

Before we start, ensure that you have the following installed on your machine:

First, let’s initialize our project. Open your terminal, navigate to your desired directory, and run the following command to create a new Node.js project:

$ mkdir oceanbase-typescript && cd oceanbase-typescript
$ npm init -y

This will create a new directory for your project and initialize a new Node.js application inside it.

Next, let’s install the necessary dependencies. We’ll need express for our server, TypeScript for writing our code, and a couple of other packages:

$ npm install express typescript ts-node nodemon

In this project, we are going to use TypeORM as the Object-Relational Mapping (ORM) to connect our project with OceanBase. TypeORM supports a wide variety of databases, including MySQL, Postgresql, and MongoDB. Since TypeORM doesn’t yet have a driver for OceanBase, and OceanBase is compatible with MySQL, we are going to use the MySQL driver for our OceanBase connection.

To use TypeORM and the MySQL driver, we need to install them in our project. Run the following command:

$ npm install typeorm mysql

Now, let’s create an src/index.ts file in the root directory of our project. This will be the entry point of our application. Here's a basic setup for our Express server:

import express from 'express';

const app = express();
const port = process.env.PORT || 3000;

app.get('/', (req, res) => {
res.send('Hello World!');
});

app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});

To run the server, we’ll use ts-node in conjunction with nodemon to ensure our server restarts whenever we make changes. Add a start script to your package.json:

"scripts": {
"start": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts",
// ... other scripts
}

Now, you can start your server by running:

npm run start

You should see the console log Server is running on port 3000. Now, make a GET request to the server at http://localhost:3000, you should see "Hello World!" displayed.

That’s it! You’ve now set up a basic TypeScript and express.js project. In the next section, we’ll connect to our OceanBase database and start building the CMS functionalities.

Connection to OceanBase

Our CMS will consist of blog posts, tags, and users. Each of these corresponds to an entity in our application. Let’s create each of these entities.

Create the following files in the src/entity directory:

  • User.ts
  • BlogPost.ts
  • Tag.ts

Let’s start with User.ts. A user in our CMS will have an ID, a name, and a list of blog posts they've authored. Here's how you can define this entity:

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"

@Entity()
export class User {

@PrimaryGeneratedColumn()
id: number

@Column()
firstName: string

@Column()
lastName: string

@Column()
age: number

}

Next, we’ll define the Tag entity in Tag.ts. A tag will have an ID, a name, and a list of blog posts that have this tag.

import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
} from "typeorm";
import { BlogPost } from "./BlogPost";

@Entity()
export class Tag {

@PrimaryGeneratedColumn()
id: number;

@Column()
name: string;

@ManyToMany(() => BlogPost, blogPost => blogPost.tags)
blogPosts: BlogPost[];
}

Finally, we’ll define the BlogPost entity in BlogPost.ts. A blog post will have an ID, a title, a slug (for the URL), the content of the post, a reference to the author, and a list of tags.

import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToMany,
JoinTable
} from "typeorm";
import { Tag } from "./Tag";

@Entity()
export class BlogPost {

@PrimaryGeneratedColumn()
id: number;

@Column()
title: string;

@Column()
content: string;

@Column()
author: string;

@Column()
excerpt: string;

@Column({
unique: true
})
slug: string;

@Column({
type: 'date'
})
publishDate: Date;

@Column({
type: 'enum',
enum: ['DRAFT', 'PUBLISHED', 'ARCHIVED'],
default: 'DRAFT'
})
status: string;

@ManyToMany(() => Tag, tag => tag.blogPosts, {
cascade: true
})
@JoinTable()
tags: Tag[];
}

Now that we have set up our entities, the next step is to connect our application to the OceanBase database. For this, we will create a DataSource object that will handle the connection.

Let’s have a look at the src/data-source.ts file:

import "reflect-metadata"
import { DataSource } from "typeorm"
import { User } from "./entity/User"
import {Tag} from './entity/Tag'
import { BlogPost } from "./entity/BlogPost"

require('dotenv').config()


export const AppDataSource = new DataSource({
type: "mysql",
host: process.env.OB_HOST,
port: Number(process.env.OB_PORT),
username: process.env.OB_USER,
password: process.env.OB_PASSWORD,
database: process.env.OB_DB,
synchronize: true,
logging: false,
entities: [User, Tag, BlogPost],
migrations: [],
subscribers: [],
}).initialize()

We first import the necessary modules and entities. Then, we use the dotenv package to load environment variables from a .env file. This is where we'll store our OceanBase connection details.

Next, we create a new DataSource object. This object is configured to connect to a MySQL database, which is compatible with OceanBase. We provide it with the necessary connection details:

  • host: The host where your OceanBase cluster is running.
  • port: The port to connect to.
  • username and password: The credentials to authenticate with the database.
  • database: The name of the database you want to connect to.

Lastly, we specify the entities that we created earlier. These are the tables that TypeORM will create in our OceanBase database.

Now, wherever we need to interact with the database in our application, we can import this AppDataSource and use it. This way, we ensure that our application is always using the same database connection.

Remember to replace the placeholders in the .env file with your actual OceanBase connection details. The .env file should look something like this:

OB_HOST=your-oceanbase-host
OB_PORT=your-oceanbase-port
OB_USER=your-oceanbase-username
OB_PASSWORD=your-oceanbase-password
OB_DB=your-oceanbase-database

With this setup, our application is now ready to store and retrieve data from OceanBase. In the next section, we’ll start building the API endpoints for our CMS.

Setting up CRUD actions for the CMS

The core of our CMS will be the CRUD (Create, Read, Update, Delete) actions. These actions will allow us to manage our content, including creating blog posts, adding tags, and retrieving posts. In this section, we will create several modules to handle these actions.

Add a tag

The first module we will create is addTag.ts, which will allow us to add new tags to our CMS. This module imports the AppDataSource and Tag entity and exports an asynchronous function addTag that takes a tag name as a parameter.

import { AppDataSource } from "../data-source";
import { Tag } from "../entity/Tag";

export async function addTag(name: string) {
const dataSource = await AppDataSource;
const tagRepository = dataSource.manager.getRepository(Tag);

const newTag = new Tag();
newTag.name = name;

const result = await tagRepository.save(newTag);

return result;
}

This function first waits for the AppDataSource to initialize, then fetches the repository for the Tag entity. It creates a new Tag instance, assigns the provided name to it, and saves this new tag to the repository. The function then returns the result of this operation.

Create a post

Next, we’ll create createPost.ts, which will allow us to create blog posts. This module is similar to addTag.ts, but it also handles the creation of tags associated with the post.

import { AppDataSource } from "../data-source";
import { BlogPost } from "../entity/BlogPost";
import { Tag } from "../entity/Tag";

export async function createPost(title: string, content: string, author: string, slug: string, publishDate: Date, status: string, tagNames: string[]) {
const dataSource = await AppDataSource;
const postRepository = dataSource.manager.getRepository(BlogPost);
const tagRepository = dataSource.manager.getRepository(Tag);

const tags = [];
for (const tagName of tagNames) {
let tag = await tagRepository.findOne({ where: { name: tagName } });
if (!tag) {
tag = new Tag();
tag.name = tagName;
tag = await tagRepository.save(tag);
}
tags.push(tag);
}

const newPost = new BlogPost();
newPost.title = title;
newPost.content = content;
newPost.author = author;
newPost.slug = slug;
newPost.publishDate = publishDate;
newPost.status = status;
newPost.tags = tags;

return await postRepository.save(newPost);
}

This function takes several parameters: title, content, author, slug, publishDate, status, and tagNames. It initializes the AppDataSource and fetches the repositories for BlogPost and Tag. It then creates a new Tag for each tag name provided, if it doesn't already exist, and saves it to the Tag repository.

A new BlogPost is then created with the provided parameters and the array of Tag instances. The post is saved to the BlogPost repository and the function returns the result of this operation.

Get all posts

The getAllPosts.ts module allows us to retrieve all blog posts from our CMS. It exports an asynchronous function getAllPosts that fetches and returns all posts from the BlogPost repository.

import { AppDataSource } from "../data-source";
import { BlogPost } from "../entity/BlogPost";

export async function getAllPosts() {
const dataSource = await AppDataSource;
return await dataSource.manager.getRepository(BlogPost).find();
}

Similarly, the getAllTags.ts module exports an asynchronous function getAllTags that fetches and returns all tags from the Tag repository.

import { AppDataSource } from "../data-source";
import { Tag } from "../entity/Tag";

export async function getAllTags() {
const dataSource = await AppDataSource;
return await dataSource.manager.getRepository(Tag).find();
}

Get a post by slug

The getPostBySlug.ts module exports an asynchronous function getPostBySlug that fetches and returns a post based on its slug.

import { AppDataSource } from "../data-source";
import { BlogPost } from "../entity/BlogPost";

export async function getPostBySlug(slug: string) {
const dataSource = await AppDataSource;
return await dataSource.manager.getRepository(BlogPost).findOne({where: {slug}});
}

Get all posts by tag

The getPostsByTag.ts module exports an asynchronous function getPostsByTag that fetches and returns all posts associated with a specific tag.

import { AppDataSource } from "../data-source";
import { BlogPost } from "../entity/BlogPost";
import { Tag } from "../entity/Tag";

export async function getPostsByTag(tagName: string) {
const dataSource = await AppDataSource;
const tagRepository = dataSource.manager.getRepository(Tag);
const tag = await tagRepository.findOne({ where: { name: tagName } });


if (!tag) {
throw new Error(`No tag found with the name ${tagName}`);
}

return await dataSource.manager.getRepository(BlogPost).find({ where: { tags: tag } });
}

This function takes a tagName as a parameter. It initializes the AppDataSource and fetches the repository for Tag. It then searches for a tag with the provided name. If no such tag is found, an error is thrown. If the tag is found, the function fetches and returns all BlogPost entities associated with this tag from the BlogPost repository.

With these files, we have set up the basic CRUD operations for our CMS. These operations allow us to create, retrieve, and manage blog posts and tags in our CMS, utilizing the power of TypeScript and OceanBase.

Setting up the API server in Express

The Express.js framework will handle the HTTP requests and responses for our CMS. In the src/index.ts file, we start by importing the necessary modules and functions:

import express, { Request, Response , NextFunction} from 'express';
import { AppDataSource } from './data-source';
import { addTag } from './modules/addTag';
import { createPost } from './modules/createPost';
import { getAllPosts } from './modules/getAllPosts';
import { getAllTags } from './modules/getAllTags';
import { getPostBySlug } from './modules/getPostBySlug';
import { getPostsByTag } from './modules/getPostsByTag';

Next, we create an Express application and set the port to 3000. We also use the express.json() middleware to parse incoming JSON payloads:

const app = express();
const port = 3000;

app.use(express.json());

We then define several routes for our CMS:

GET /posts: This route fetches all blog posts from the CMS and sends them in the HTTP response.

app.get('/posts', async (req: Request, res: Response) => {
const posts = await getAllPosts();
res.json(posts);
});

POST /posts: This route creates a new blog post. It expects the post details to be provided in the HTTP request body.

app.post('/posts', async (req: Request, res: Response, next: NextFunction) => {
try {
const { title, content, author, slug, publishDate, status, tagNames } = req.body;
await createPost(title, content, author, slug, publishDate, status, tagNames);
res.sendStatus(201);
} catch (error) {
next(error);
}
});

POST /tags: This route creates a new tag. It expects the tag name to be provided in the HTTP request body.

app.post('/tags', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name } = req.body;
await addTag(name);
res.sendStatus(201);
} catch (error) {
next(error);
}
});

GET /tags: This route fetches all tags from the CMS and sends them in the HTTP response.

app.get('/tags', async (req: Request, res: Response, next: NextFunction) => {
try {
const tags = await getAllTags();
res.json(tags);
} catch (error) {
next(error);
}
});

GET /posts/:slug: This route fetches a blog post based on its slug and sends it in the HTTP response.

app.get('/posts/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const { slug } = req.params;
const post = await getPostBySlug(slug);
if (post) {
res.json(post);
} else {
res.sendStatus(404);
}
} catch (error) {
next(error);
}
});

GET /posts/tag/:tagName: This route fetches all blog posts associated with a specific tag and sends them in the HTTP response.

app.get('/posts/tag/:tagName', async (req: Request, res: Response, next: NextFunction) => {
try {
const { tagName } = req.params;
const posts = await getPostsByTag(tagName);
res.json(posts);
} catch (error) {
next(error);
}
});

We also add an error handling middleware to catch any errors that might occur during the handling of requests:

app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
console.error(error);
res.status(500).json({ error: 'Internal server error' });
});

Finally, we start the Express server and add event listeners to handle graceful shutdowns:

app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});

process.on('SIGINT', async () => {
const conn = await AppDataSource;
await conn.close();
console.log('Database connection closed');
process.exit(0);
});

process.on('unhandledRejection', (reason, promise) => {
console.log('Unhandled Rejection at:', promise, 'reason:', reason);
process.exit(1);
});

That’s it! With these steps, we have set up the API server for our CMS using Express.js, TypeScript, and OceanBase.

Testing the API

Now that we have our API server all set up, it’s time to test it out. We will use the VS Code plugin Thunder Client to make HTTP requests to our server and check its responses.

Type the following command to start the server.

npm run start

Let’s start by testing the POST /tags endpoint that we created to add new tags to our CMS. Open Postman and create a new request. Set the request type to POST and the URL to http://localhost:3000/tags. In the request body, select raw and JSON and input a new tag name as follows:

{
"name": "Technology"
}

Click on “Send”. If everything is working correctly, you should receive a 201 Created status code.

Next, let’s test the POST /posts endpoint. Create a new POST request to http://localhost:3000/posts. In the request body, input a new blog post with title, content, author, slug, publish date, status, and an array of tag names:

{
"title": "The Future of CMS",
"content": "This is a blog post about the future of CMS.",
"author": "John Doe",
"slug": "the-future-of-cms",
"publishDate": "2023-12-01",
"status": "PUBLISHED",
"tagNames": ["Technology", "CMS"]
}

Click on “Send”. You should receive a 201 Created status code.

Now, let’s test the GET /posts endpoint. Create a new GET request to http://localhost:3000/posts and click on "Send". You should receive a 200 OK status code and a list of all blog posts in the response body.

Similarly, test the GET /tags endpoint by creating a new GET request to http://localhost:3000/tags. You should receive a 200 OK status code and a list of all tags in the response body.

To test the GET /posts/tag/:tagName endpoint, create a new GET request to http://localhost:3000/posts/tag/Technology. You should receive a 200 OK status code and a list of all blog posts associated with the tag "Technology".

Finally, to test the GET /posts/:slug endpoint, create a new GET request to http://localhost:3000/posts/the-future-of-ai. You should receive a 200 OK status code and the details of the blog post with the slug "the-future-of-ai".

Congratulations! If all your requests returned the expected responses, your API server is working as expected. You’ve successfully built a headless CMS using TypeScript and OceanBase and equipped it with a robust API server using Express.js.

Conclusion

Building a headless CMS might seem like a daunting task, but as we’ve seen in this tutorial, with the right tools and a solid structure, it’s entirely achievable. Using TypeScript and OceanBase, we’ve created a flexible and scalable CMS that can serve content on a variety of platforms.

OceanBase, a distributed, high-performance relational database, served as the backbone of our CMS. Its high scalability and compatibility with MySQL made it an excellent choice for projects like this. OceanBase not only handled vast amounts of data efficiently but also provided reliability, ensuring that our CMS can handle large-scale applications.

TypeScript’s static typing enhanced our code’s reliability, predictability, and maintainability, making it a powerful ally in our quest to build a robust headless CMS. Express.js, a lightweight and flexible Node.js web application framework, enabled us to build a sleek API server with minimal hassle.

This guide served as a demonstration of how you can use OceanBase in a TypeScript project. It’s a testament to OceanBase’s versatility and robustness, proving that it can be an excellent choice for a wide range of applications, from small projects to large-scale enterprise solutions.

Remember, the journey doesn’t end here. There’s a plethora of additional features and enhancements you can add to your CMS. From incorporating user authentication to adding a front end to interact with your CMS, the possibilities are endless. With OceanBase as the backbone of your project, you can be confident that your CMS will be able to handle whatever you throw at it.

Again, you can find the source code of the project on this GitHub repo. The repo can also be a starting point for any other TypeScript/OceanBase projects in your mind.

--

--

Wayne S.
OceanBase Database

OceanBase evangelist and an independent developer of Google Workspace apps. Worked on various projects in NLP, data automation, and semantic search.