In this blog, I'll tell you how I build my own CMS to manage my contents on this website.
Why not use Strapi?
First of all, why not just use Strapi? Strapi is a great CMS, but we only manage a few simple contents like blog markdown and information on my work. So, using Strapi seems to be overengineering, and I can save my money by not hosting Strapi.
What do I need in my CMS?
Overall, I want a Strapi-like UI for managing my blogs and works. It should look something like this.
Overall UI, left side is a navigation tab and right side is a page
Create/Edit blog UI.
And I don't want to host it, so I'll only use development server to run this web and push changes to Github after our contents have been handled. I'll be using YAML files as a database because we can easily manage YAML files by using this package js-yaml. Other options might be SQLite or MySQL. So we also need a backend code using
js-yaml
package to manage my database.
How do I build my own CMS?
Before we build anything, let's set up SvelteKit project first.
I run npm init svelte .
in admin/
folder and use all options in the image below.
Backend
Next, let's create our backend. I want 2 types of data schema for blog and work.
Blog Data Schema
BlogContent
interface will be our input and Blog
will be our object to save on our database.
ts// admin/src/lib/blog.ts
export interface BlogContent extends JSONObject {
title: string;
description: string;
content: string;
}
export interface Blog extends BlogContent {
htmlContent: string;
slug: string;
createdAt: string;
updatedAt: string;
}
Work Data Schema
Same with the Blog Data Scheme, WorkContent
will be our input and Work
will be our object to save on our database.
ts// admin/src/lib/work_manager.ts
export interface Link extends JSONObject {
href: string;
text: string;
}
export interface WorkContent extends JSONObject {
title: string;
tags: string[];
links: Link[];
body: string;
}
export interface Work extends WorkContent {
id: number;
}
And now we need a backend code to manage these data schemas. First, I'll write some tests for our backend code.
Backend Testing & Development
I'll use Jest to test our backend code. First install it via npm install -D jest ts-jest @types/jest
and in package.json
add jest configuration for ts
using ts-jest
and ECMAScript Modules configuration.
json// admin/package.json
{
...,
"jest": {
"testEnvironment": "node",
"transform": {
".[jt]sx?$": "ts-jest"
},
"preset": "ts-jest/presets/default-esm",
"globals": {
"ts-jest": {
"useESM": true
}
},
"moduleNameMapper": {
"^(.{1,2}/.*).js$": "$1"
}
},
"scripts": {
...,
"jest": "node --experimental-vm-modules node_modules/jest/bin/jest.js ./src",
},
}
In our backend, we should be able to add, edit, delete and get from our database and these methods should be in our data structure class BlogManager
and WorkManager
. So we need to write a test for these methods.
An example of my test cases.
ts// admin/src/lib/blog.test.ts
const path = './tests/data/blogs';
const bm = new BlogManager(path);
test('Can add new blog?', () => {
const title = 'test add';
const blog: BlogContent = {
title,
description: 'test blog',
content: 'Lorem .....'
};
const ret = bm.add(blog);
expect(ret).toEqual(true);
// file name is a slug of title
const resultPath = `${path}/test-add.yaml`;
const result = yaml.load(readFileSync(resultPath, 'utf-8')) as BlogContent;
expect(result).toBeDefined();
expect(result.title).toEqual(blog.title);
expect(result.description).toEqual(blog.description);
expect(result.content).toEqual(blog.content);
// remove that blog after done testing
if (existsSync(resultPath)) unlinkSync(resultPath);
});
And let's implement BlogManager
class that will pass this test case.
ts// admin/src/lib/blog.ts
export default class BlogManager {
path: string;
constructor(path: string) {
this.path = path;
}
static slug(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/ /g, '-')
.replace(/[.\\\\/:*?\"<>|]/g, '');
}
add(blog: BlogContent): boolean {
const slug = BlogManager.slug(blog.title);
const path = `${this.path}/${slug}.yaml`;
if (!existsSync(path)) {
const date = new Date().toISOString();
const data = blog as Blog;
data.htmlContent = md(blog.content);
data.slug = slug;
data.createdAt = date;
data.updatedAt = date;
writeFileSync(path, yaml.dump(data));
return true;
}
return false;
}
}
For WorkManager
and other methods, the development process is the same as above. You can see all the codes on admin/src/lib.
API Routes
Now, I need to create an API that our frontend web can use because we can't use BlogManager
and WorkManager
class directly on SvelteKit page. In SvelteKit, we can create our API by using Standalone endpoints
Therefore, we will use BlogManager
and WorkManager
class to create our REST API. Where our endpoints request
will contain any information necessary for our data structure class.
An example of my endpoints. For full source code, you can visit admin/src/routes/api.
ts// admin/src/routes/api/post.ts
const bm = new BlogManager('../src/data/blogs');
export const post: RequestHandler = async ({ request }) => {
const data = (await request.json()) as BlogContent;
if (bm.add(data)) return { status: 200 };
return { status: 500 };
};
...
Frontend
Lastly, let's create our frontend using SMUI and tailwindcss. Setting up SMUI can be done by following this guide (I wish this could be simpler) and setting up tailwindcss by following this guide but in app.css
only add @tailwind components
and @tailwind utilities
because @tailwind base
will override some of SMUI styling.
Overview After all the setup, I need to create all of SvelteKit Pages that we will need.
This is a list of svelte files on admin/src/routes
for all of our pages.
PS>> tree /f
| index.svelte
| __layout.svelte
|
|---blog
| | create.svelte
| | index.svelte
| |
| |---edit
| [slug].svelte
|
|---works
| create.svelte
| index.svelte
|
|---edit
[id].svelte
__layout.svelte
is a file that will contain our layout for every page. (You can read more on SvelteKit Doc). So our navigation between 2 of content types will be here and it'll look like the image below.
Both
blog
and works
routes are similar because they need to have these nested routes.
index
is a page that will show us a list of our contents and some buttons to manage those contents. It'll look like the right side of a picture in What I need in my CMS? section.create
andedit/[some identifier]
is a page that will show us what information we need to provide to that content type. It'll look like the picture in What I need in my CMS? section too.
Implementation
First, __layout.svelte
is a simple svelte component with a few SMUI components inside it, nothing interesting.
Next, index.svelte
will contain SMUI Data Table with data from our API by using SvelteKit Loading by below code (blog route example).
svelte<script lang="ts" context="module">
import type { Load } from '@sveltejs/kit';
import type { Blog } from '$lib/blog';
export const load: Load = async ({ fetch }) => {
const resp = await fetch('/api/post');
const posts = (await resp.json()) as Blog[];
return { props: { posts } };
};
</script>
Last, let's look at how I implement create.svelte
and edit/[slug].svelte
of blog route. Both of these pages will contain the same component Blogcard
that will has properties of type Blog
. Blogcard
will provide us with data visualization and input forms.
So in create.svelte
, we will have an empty Blogcard
that we need to fill the forms and we can save those data by fetching our API (below code).
svelte<script lang="ts">
import { goto } from '$app/navigation';
import type { BlogContent } from '$lib/blog';
import Blogcard from '$lib/components/blogcard.svelte';
const handleSave = async (event: any) => {
const blogpost = event.detail.blogpost as BlogContent;
const res = await fetch('/api/post', {
method: 'POST',
body: JSON.stringify(blogpost)
});
if (res.status === 200) {
goto(`/blog/edit/${event.detail.slug}`);
}
};
</script>
<Blogcard on:save={handleSave} />
Did you see something interesting? On <Blogcard on:save={handleSave} />
, what is on:save={handleSave}
?. It's a Svelte Event Forwarding mechanism. By using the below code on Blogcard
component, we can handle Blogcard
save button behavior on an upper component.
tsconst dispatch = createEventDispatcher();
const onSave = async () => {
if (filled) { // check if forms if filled
const blogpost: BlogContent = {
title,
description,
content
};
dispatch('save', { blogpost, slug }); // dispatch event data
}
};
Now, in edit/[slug].svelte
there is significant only one changes from create.svelte
.
We are using square brackets on Svelte component file. This is a SvelteKit dynamic parameters.
From SvelteKit Doc Dynamic parameters are encoded using_
[brackets]
. For example, a blog post might be defined by_src/routes/blog/[slug].svelte
. These parameters can be accessed in a_load
_function or via the_page
_store.
Now we can use dynamic parameters to fetch our blog content that we want to edit and show it on Blogcard
by providing its properties with fetched blog content. We can use Svelte Spread Props for more compact code too.
Above paragraph in code.
svelte<script lang="ts" context="module">
import type { Load } from '@sveltejs/kit';
import type { Blog, BlogContent } from '$lib/blog';
export const load: Load = async ({ fetch, params }) => {
const resp = await fetch(`/api/post?slug=${params.slug}`);
const post = (await resp.json()) as Blog;
return { props: { post } };
};
</script>
<script lang="ts">
export let post: Blog;
</script>
<Blogcard {...post} on:save={handleSave} />
For works
route the implementation is the same as blog
route. You can view all source code on TanatBlog repo.
Conclusion
Yeyyy! I finished creating my own CMS for my blog. We have learned how to create basic SvelteKit web-app with SMUI and tailwindcss and using Jest to test backend too. If you have any question please comment below or contact me, and be sure to check TanatBlog repo.
This project was fun, but it took way too many times for me 😅. Hope this blog will help others do similar project faster!!!