DEV Community

Aditya
Aditya

Posted on

Headless blog using Garchi CMS & Nuxt 3 - Part 2

In part 1, we set up our blog's backend with the Garchi CMS. Let's now move forward to constructing the frontend using Nuxt 3.

To begin, ensure the latest version of Node JS is installed on your system.

In your terminal, execute the following command to create a new Nuxt 3 application. You're free to replace 'blog' with any other name that you prefer.

npx nuxi@latest init blog
Enter fullscreen mode Exit fullscreen mode

After execution, you should see an output like the following:

Image description

Now, navigate into the 'blog' folder and run npm i to install the necessary dependencies.

cd blog
npm i
Enter fullscreen mode Exit fullscreen mode

To expedite the process, I'll bypass the fundamentals of Nuxt 3 and Tailwind. You can find details on integrating Tailwind with Nuxt 3 here

Next, incorporate the API key created in the part 1 within our nuxt.config.ts file and add the API base URL as shown in the image.

Image description

We'll swap NuxtWelcome with NuxtPage in app.vue and create index.vue in the 'pages' directory.

Remember, it's advisable to employ Garchi API server-side due to the API key's billing purposes. Hence, let's create an API route in Nuxt 3 by generating a file named 'blog.get.ts' which will map to the /api/blog GET request. This file should be located within the 'server/api' directory.

Include the following code within 'blog.get.ts':

export default defineEventHandler(async (event) => {
    const categoryId = 71
    const config = useRuntimeConfig()

    const response = await $fetch(`${config.API_URL}/products/filter`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${config.API_KEY}`
        },
        body: {
            categories: [categoryId]
        }
    })

    return response
})
Enter fullscreen mode Exit fullscreen mode

Let's dissect the code:

  1. We will utilize Garchi CMS's filter products API, which can be found here This API accepts a list of category ids, space ids, or priceOrder (for e-commerce) and filters based on that. From our Garchi CMS dashboard, we know the category id and will use that. We'll pass it as an array since the API accepts an array of ids. Consequently, this should return all the items categorised as 'blog' since 71 (in my case) is the id of the 'blog' category.
  2. We make a POST request to https://garchi.co.uk/api/v1/products/filter and for now, we'll merely return the response to examine the output.

As this is a GET API from the Nuxt end, we can execute it in the browser to view the results. Hence, we'll start the dev server with:

npm run dev
Enter fullscreen mode Exit fullscreen mode

Image description

We'll employ this information to display our list of blog posts. Therefore, I'll construct a component within the 'components' folder and label it 'BlogCard.vue'. But first, let's create types based on the API response we received. So within 'assets/types/Post.ts' include this code:


export type PostMeta = {
    id: number
    key: string
    value: string
    type: string
}

export type Post = {
    product_id: number
    slug: string
    name: string
    description?: string
    categories: {
        id: number
        name: string
    }[]
    main_image: string
    space: {
        uid: string
        name: string
    }
    product_meta: PostMeta[]
}
Enter fullscreen mode Exit fullscreen mode

The code in our 'BlogCard.vue' should look like this:

<template>
  <article class="flex flex-col items-start justify-between">
    <div class="relative w-full">
      <img
        :src="post.main_image"
        alt=""
        class="aspect-[16/9] w-full rounded-2xl bg-gray-100 object-cover sm:aspect-[2/1] lg:aspect-[3/2]"
      />
      <div
        class="absolute inset-0 rounded-2xl ring-1 ring-inset ring-gray-900/10"
      />
    </div>
    <div class="max-w-xl">
      <div class="mt-8 flex items-center gap-x-4 text-xs">
        <NuxtLink
          class="relative z-10 rounded-full bg-gray-50 px-3 py-1.5 font-medium text-gray-600 hover:bg-gray-100"
        >
          {{ categoryTitle }}
        </NuxtLink>
      </div>
      <div class="group relative">
        <h3
          class="mt-3 text-lg font-semibold leading-6 text-gray-900 group-hover:text-gray-600"
        >
          <NuxtLink :to="`/blog/${post.slug}`">
            <span class="absolute inset-0" />
            {{ post.name }}
          </NuxtLink>
        </h3>
      </div>
      <div class="relative mt-8 flex items-center gap-x-4">
        <img
          :src="authorImage"
          alt=""
          class="h-10 w-10 rounded-full bg-gray-100"
        />
        <div class="text-sm leading-6">
          <p class="font-semibold text-gray-900">
            <span>
              <span class="absolute inset-0" />
              {{ authorName }}
            </span>
          </p>
        </div>
      </div>
    </div>
  </article>
</template>

<script setup lang="ts">
import { Post, PostMeta } from "~/assets/types/Post";

const postProps = defineProps<{
  post: Post;
}>();

const categoryTitle = computed(() =>
  postProps.post?.categories.map((category) => category.name).join(", ")
);

const authorImage = computed(
  () =>
    postProps.post?.product_meta?.find((m) => m.key == "avatar")?.value ?? ""
)
const authorName = computed(
  () =>
    postProps.post?.product_meta?.find((m) => m.key == "author")?.value ?? ""
)
</script>
Enter fullscreen mode Exit fullscreen mode

If you notice, I have taken authorImage, authorName and categoryTitle as computed properties. This is because authorImage and authorName will be inside the meta detail (product_meta) as we added these values as extra fields. Category will be an array of categories so we will join it using ', '.

Next inside our index.vue we will have something like below

<template>
    <Head>
        <Title>Blog</Title>
    </Head>
    <div class="max-w-6xl p-2 mx-auto xl:py-20
    grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 md:gap-5">
        <BlogCard v-for="(post, index) in data?.data" :key="index" :post="post" />
    </div>
</template>

<script setup lang="ts">
const {data} = await useFetch("/api/blog")
</script>

Enter fullscreen mode Exit fullscreen mode

Next, we will create a dynamic route which will render our blog article on click. So inside pages/blog create [slug].vue file. You can also create a layout to have less code rewrite. But for this example, I will rewrite the classes, sorry about that :)

Also a layout can be created so that we have less code rewrite. But for this example I will rewrite the classes. Sorry about that :)

Previously, we created an API route. This time we could utilise Nuxt 3's useAsyncData with the server key set to true, so it runs on the server. We will use Get Product Details of Garchi CMS.

Since the API returns HTML in the description, we need to clean it using the DOMPurify package. We can also allow only iframe tags for YouTube so let's do that.

First, install dompurify:

npm i dompurify
Enter fullscreen mode Exit fullscreen mode

If you are using TypeScript, you might need to add the types:

npm i --save-dev @types/dompurify
Enter fullscreen mode Exit fullscreen mode

Our [slug].vue should look something like this:

<template>
  <Head>
    <Title>{{ article?.name }}</Title>
  </Head>
  <div class="max-w-6xl p-2 mx-auto xl:py-20 flex flex-col gap-9">
    <img
      :src="article?.main_image"
      alt=""
      class="aspect-square w-full xl:w-[30%] xl:h-1/2 mx-auto rounded-2xl bg-gray-100 object-cover object-center"
    />

    <h1 class="text-4xl">
      {{ article?.name }}
    </h1>

    <div class="flex items-center space-x-3">
      <img
        :src="authorImage"
        :alt="authorName"
        class="w-16 h-16 rounded-full ring-2 ring-gray-900 ring-offset-gray-800"
      />
      <span class="text-gray-600 font-semibold text-lg">
        By {{ authorName }}
      </span>
    </div>

    <div
      class="prose max-w-full"
      v-html="description || article?.description"
    ></div>
  </div>
</template>

<script setup lang="ts">
import { Post } from "assets/types/Post";
import * as DOMPurify from "dompurify";

const config = useRuntimeConfig();
const route = useRoute();

const { data, error } = await useAsyncData<{ data: Post[] }>(
  `article-${route.params.slug}`,
  () => {
    return $fetch(`${config.API_URL}/product/${route.params.slug}`, {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${config.API_KEY}`,
      },
    });
  },
  {
    server: true,
  }
);

const article = computed<Post | null>(() => data.value?.data[0] ?? null);

const description = ref<string>("");

const authorImage = computed(
  () => article.value?.product_meta?.find((m) => m.key == "avatar")?.value ?? ""
);
const authorName = computed(
  () => article.value?.product_meta?.find((m) => m.key == "author")?.value ?? ""
);

if (error.value || !article.value) {
  throw createError({ statusCode: 404, message: "Not found" });
}

onMounted(() => {
  DOMPurify.addHook("uponSanitizeElement", (node: HTMLElement, data: any) => {
    if (data.tagName == "iframe") {
      const allowedSRC = "https://www.youtube.com/embed";
      const src = node.getAttribute("src");
      if (!src?.startsWith(allowedSRC)) {
        return node.parentNode?.removeChild(node);
      }
    }
  });

  let content = DOMPurify.sanitize(article.value?.description, {
    USE_PROFILES: { html: true },
    ADD_TAGS: ["iframe"],
    ADD_ATTR: ["autoplay", "allowfullscreen", "frameborder", "scrolling"],
  });

  description.value = content;
});
</script>
Enter fullscreen mode Exit fullscreen mode

Let's break down the code step by step:

The API returns data in the format {data: Post[]}

We have a computed property which gives us data:Post[0]. This is because the API, which accepts either a slug or an item's id as a URL parameter, will return only one item or none in the response array.

We are using DOMPurify to sanitize our article.description which is in HTML format and allow only certain iframes. As this is going to happen on the client side because of the onMounted hook, I am showing article.description that comes from the server and switch it with client side description when it is sanitized. This can be handled completely on the server side, I guess.

Finally, we spice it up with the Tailwind Typography plugin.
The final output is:

Image description

Image description

Image description

To conclude, the main intention of this two-part article is to demonstrate the usage of Garchi CMS and a quick demo of how it could be used.

That's all for now, hope you enjoyed it :)

Top comments (0)