Build a Blog Post Preview with Strapi Draft and Publish and Astro

Strapi - Oct 11 - - Dev Community

Introduction

This article is a tutorial on how to build a blog post preview feature using Astro and Strapi, a web framework for content sites.

We will show you how to achieve this from a technical standpoint. For this, we will use the new Drafts & Publish feature that was released in Strapi 5, visit Strapi docs for more information. Furthermore, we'll make use of Astro's static generation functionality to build a blog that meets the highest performance standards so you can climb the SEO rankings.

What we are Building

We will build a blog post preview feature. This makes it possible to keep new blog posts private before you choose to publish them. Before you publish new posts, you can make them available as a draft. Draft posts won't show up on the home page and are only accessible to those who know the link. So what are the benefits of this? You can use this to present upcoming blog posts to your fellow team members to receive immediate feedback.

We will leverage the new Strapi 5 Strapi Document Service API and its new functions publish(), unpublish(), and discardDraft() functions to allow changing visibility of blog posts directly through the Astro frontend. This will be a password-protected feature only available to authorized users.

So let's get started!

Introducing the Tech Stack: Astro, Strapi, and Tailwind

Astro is a web framework for content-driven websites, which makes it ideal for the blog we're building. It will render the blog posts and has special support for internationalization and static generation. Styling will be done with Tailwind CSS. Finally, the content will live in the powerful Strapi CMS.

Requirements

All you need for this project is to have at least Node.js version 18 installed on your computer and accessible from the terminal.

Enter the following command into your terminal to check:

$ node --version
v20.11.0
Enter fullscreen mode Exit fullscreen mode

If you don't have Node.js installed, head here to install it.

Setting up Strapi

First, we'll need to set up Strapi. Run the command below in your terminal to set up a new Strapi project:

npx create-strapi@latest drafts-and-publish-blog
Enter fullscreen mode Exit fullscreen mode

In the prompt, choose the following answers:

? Please log in or sign up. Skip
? Do you want to use Typescript ? No
? Use the default database (sqlite) ? Yes
Enter fullscreen mode Exit fullscreen mode

Once the setup is complete, change into the project folder and start Strapi:

cd ./drafts-and-publish-blog
npm run develop
Enter fullscreen mode Exit fullscreen mode

You will now be taken to a sign-up page in the browser. Fill out the form.

welcome to strapi.png

After signing up, you will be taken to the Strapi Dashboard. In the next step, we will create the collection for the blog posts with a draft or published state.

strapi admin dashboard.png

Create Strapi Post Collection

Create a new Collection Type

Let's create our collection. In the sidebar, click on Content-Type Builder and then Create new collection type.

create a new collection.png

Call the collection type Post. Do not press Continue just yet, as we will need to modify the advanced settings.

create post collection.png

Navigate to the Advanced Settings and make sure the Draft & publish checkbox is selected. We'll use that flag to differentiate between Draft and Published Posts.

enable draft and publish.png

Create Fields for Post Collection

Now, we need to define the fields our posts should have. Add the following fields:

  • title: Text, Short Text, Required field
  • content: Rich Text (Markdown), Required field, do not use Rich Text (Blocks)
  • header: Media (image only), Single Media, Optional field
  • slug: UID, attached to title

Create collection fields.png

Once you're done, click the Save button. We now have the collection we need to start writing content. Let's go ahead and create two posts: one that is in the Draft state and one that is already Published.

Create Entries

Click on Content Manager, Post, and then Create new entry.

create entries.png

You can write whatever you want here. We will be using Lorem Ipsum.

Create Draft Entry

create draft entry.png

Click Save, but do not publish the post yet. We will use this one as a draft. You can add another post that you can publish immediately.

Create Published Entry
Create Published Entry.png

Once you're ready, click Save and Publish to publish the post. If you want, you can go ahead and create another post so that we have more than one post to show on the website.

published entries.png

Making Posts Publicly Accessible

We now have posts in a defined collection, but no way to access them from Astro. To change this, we need to ensure they can be accessed through the Strapi API. For this, navigate to Settings > Users & Permissions Plugin > Roles.

Making Posts Publicly Accessible.png

In this section, select the Public role to grant find and findOne permissions to the Post collection.

enable permission.png

Ensure that no other permissions are granted to the Public role, as this would allow anyone to modify or delete your content. Once done, press Save.

Now you'll be able to access your posts through the Strapi API at http://localhost:1337/api/posts. Try it out to make sure everything has worked. This is the API we will consume on our frontend to render the posts. With this, we're ready to set up the Astro for our blog. Be aware that you will only see Published posts here. We will deal with accessing Drafts later.

Getting Started with Astro

Bootstrappping Astro Project

First, we'll need to set up Astro. Run the command below in your terminal to set up a new Astro site:

npm create astro@latest astro-blog
Enter fullscreen mode Exit fullscreen mode

Select the following choices in the installation sequence:

 astro   Launch sequence initiated.

      ◼  dir Using astro-blog as project directory

  tmpl   How would you like to start your new project?
         Include sample files

    ts   Do you plan to write TypeScript?
         No
      ◼  No worries! TypeScript is supported in Astro by default,
         but you are free to continue writing JavaScript instead.

  deps   Install dependencies?
         Yes

   git   Initialize a new git repository?
         No
      ◼  Sounds good! You can always run git init manually.

      ✔  Project initialized!
         ■ Template copied
         ■ Dependencies installed

  next   Liftoff confirmed. Explore your project!

         Enter your project directory using cd ./astro-blog
         Run npm run dev to start the dev server. CTRL+C to stop.
         Add frameworks like react or tailwind using astro add.

         Stuck? Join us at https://astro.build/chat

╭─────╮  Houston:
│ ◠ ◡ ◠  Good luck out there, astronaut! 🚀
╰─────╯
Enter fullscreen mode Exit fullscreen mode

Adding Tailwind CSS

Now that we have installed Astro, change to the astro-blog directory. Then, we want to add Tailwind CSS to style our blog. For this, first run this command in your terminal and say Yes to all choices:

npx astro add tailwind
Enter fullscreen mode Exit fullscreen mode

This has automatically set up Tailwind CSS in Astro for us. However, we also need the Tailwind Typography plugin so that we can style the prose in the posts. So once again, open your terminal and run:

npm install -D @tailwindcss/typography
Enter fullscreen mode Exit fullscreen mode

Modify your tailwind.config.mjs file so that it imports the Typography plugin:

/** @type {import('tailwindcss').Config} */
export default {
  content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
  theme: {
    extend: {},
  },
  plugins: [require("@tailwindcss/typography")],
};
Enter fullscreen mode Exit fullscreen mode

Also, head over to the astro.config.mjs and to enable Server-Side Rendering with output: "server". This allows us to change the visibility of a post whenever we want, without re-building the whole site.

import { defineConfig } from "astro/config";

import tailwind from "@astrojs/tailwind";

// https://astro.build/config
export default defineConfig({
  output: "server",
  integrations: [tailwind()],
});
Enter fullscreen mode Exit fullscreen mode

Now start the application using npm run dev and visit http://localhost:4321/ to see the Astro site.

welcome to astro.png

Building the App UI Layout

Our blog will consist of two pages:

  • /blog, which will show all blog posts.
  • /blog/[slug], which will display a single blog post.

Let's get started with building this common layout. Create a new file at /src/layouts/BlogLayout.astro with the following content:

---
import Layout from "./Layout.astro";

interface Props {
  title: "string;"
}

const { title } = Astro.props;
---

<Layout title={title}>
  <header class="bg-blue-600 text-white text-center py-4">
    <h1 class="text-3xl font-bold">
      <a href="/blog" class="text-white"> Astro Blog </a>
    </h1>
  </header>

  <slot />
</Layout>
Enter fullscreen mode Exit fullscreen mode

See how we import the already existing layout from /src/layouts/Layout.astro. While you're at it, open this file and remove the <style> tag, since we're using Tailwind for everything. Afterwards, it should look like this:

---
interface Props {
  title: string;
}

const { title } = Astro.props;
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="description" content="Astro description" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="generator" content={Astro.generator} />
    <title>{title}</title>
  </head>
  <body>
    <slot />
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Create Environment Variable

Now we have the building blocks we need to render an overview of all blog posts. First, create a .env file in the root of your project and add the API URL there:

STRAPI_URL=http://localhost:1337
Enter fullscreen mode Exit fullscreen mode

We will use this to call the Strapi API from Astro. If you deploy your site, this will need to be set to the Strapi production URL instead of localhost.

Render all Posts

Create a new file at /src/pages/blog/index.astro, which will serve as the overview page for our blog. Add this content to the file:

---
import BlogLayout from "../../layouts/BlogLayout.astro";

const response = await fetch(
  `${import.meta.env.STRAPI_URL}/api/posts?populate=header`
);
const data = await response.json();
const posts = data.data;
---

<BlogLayout title="All Posts">
  <main class="container mx-auto mt-8 px-4">
    <div class="space-y-6 max-w-lg mx-auto">
      {
        posts.map((post) => {
          const headerImageUrl = post.header?.url;
          return (
            <a
              href={`/blog/${post.slug}`}
              class="block bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300 max-w-md mx-auto"
            >
              {headerImageUrl && (
                <img
                  src={`${import.meta.env.STRAPI_URL}${headerImageUrl}`}
                  alt="Article Header Image"
                  class="w-full h-48 object-cover"
                />
              )}
              <div class="p-4">
                <h2 class="text-xl font-bold mb-2">{post.title}</h2>
                <p class="text-gray-600 text-sm">
                  Published on:{" "}
                  {new Date(post.publishedAt).toLocaleDateString()}
                </p>
              </div>
            </a>
          );
        })
      }
    </div>
  </main>
</BlogLayout>
Enter fullscreen mode Exit fullscreen mode

What this does is fetch all posts from our Strapi API. The ?populate=header parameter tells Strapi to include the header image we've set for our posts. Astro now fetches this information and renders a page where we can view the previews for all blog posts.

render all posts.png

Go and check it out in your browser at http://localhost:4321/blog. When you publish a new post in Strapi, it will automatically show up on this page. You might have noticed that a 404 NOT FOUND error occurs whenever you try to click on a blog post preview link. This is because we haven't created this page yet.

Build the Post Detail View

Let's get started with building the post detail view, where we will be able to see the post with all its content.

First, we'll need to install the marked library, which will convert Strapi's Markdown into HTML:

npm install marked
Enter fullscreen mode Exit fullscreen mode

Now, create a new file at /src/pages/blog/[slug].astro. This will act as a catch-all for any post. Add the following content to the file:

---
import { marked } from "marked";
import BlogLayout from "../../layouts/BlogLayout.astro";
import Visibility from "../../components/Visibility";

const { slug } = Astro.params;

const publishedResponse = await fetch(
  `${import.meta.env.STRAPI_URL}/api/posts?populate=header&filters[slug][$eq]=${slug}`
);

const publishedData = await publishedResponse.json();

let post = publishedData.data[0];

if (!post) {
  return new Response("Post not found", { status: 404 });
}

const headerImage = post.header?.url;

const publishDate = new Date(post.publishedAt).toLocaleDateString("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric",
});
---

<BlogLayout title={post.title}>
  <article class="prose prose-lg max-w-2xl mx-auto py-24">
    {
      headerImage && (
        <img
          src={`${import.meta.env.STRAPI_URL}${headerImage}`}
          alt={post.title}
          class="mb-6 w-full h-auto rounded-lg"
        />
      )
    }
    <div class="flex items-center justify-between mb-4">
      <h1 class="mb-0">{post.title}</h1>
    </div>
    {publishDate && <p class="text-gray-500 mt-2">{publishDate}</p>}
    <div set:html={marked.parse(post.content)} />
  </article>
</BlogLayout>
Enter fullscreen mode Exit fullscreen mode

First, we fetch the post by using Strapi's filter in the API query. If it doesn't exist, we throw a 404 NOT FOUND error. After finding the correct post, we can extract the header image and publication date.

The rest of the code is to render the post information and actual content. To turn the Markdown content into HTML, we use the marked library. With the Tailwind classes prose prose-lg, all elements of the article automatically get styled. So the HTML output from our Markdown parser is styled without us needing to do anything additional.

post detail view.png

Now we have a working, fully static blog. However, we have no way to access the draft post.

Making Drafts Accessible

We have already added the draft post to Strapi earlier, so there is no need to change anything within Strapi. Let's see how we can access drafts. By default, the API call only returns published articles, which is what we want.

However, if a user has the direct URL to a draft post, they should be able to access it. An article can have both a published and a draft version. This allows improving upon an already published article. So draft articles must be available under a different URL. So let's create a new page for this.

Create Page for Draft Posts

Create a new file under /src/pages/blog/draft/[slug].astro. In there, add the following code:

---
import { marked } from "marked";
import BlogLayout from "../../../layouts/BlogLayout.astro";
import Discard from "../../../components/Discard";
import Visibility from "../../../components/Visibility";

const { slug } = Astro.params;

const draftResponse = await fetch(
  `${import.meta.env.STRAPI_URL}/api/posts?populate=header&filters[slug][$eq]=${slug}&status=draft`
);

const draftData = await draftResponse.json();

let post = draftData.data[0];

if (!post) {
  return new Response("Draft post not found", { status: 404 });
}

const headerImage = post.header?.url;
---

<BlogLayout title={post.title}>
  <article class="prose prose-lg max-w-2xl mx-auto py-24">
    {
      headerImage && (
        <img
          src={`${import.meta.env.STRAPI_URL}${headerImage}`}
          alt={post.title}
          class="mb-6 w-full h-auto rounded-lg"
        />
      )
    }
    <div class="flex items-center justify-between mb-4">
      <h1 class="mb-0">{post.title}</h1>
    </div>
    <div set:html={marked.parse(post.content)} />
  </article>
</BlogLayout>
Enter fullscreen mode Exit fullscreen mode

We specifically search for draft versions of posts. While fetching, we filter by slug so we only get the relevant post.

const draftResponse = await fetch(
  `${import.meta.env.STRAPI_URL}/api/posts?populate=header&filters[slug][$eq]=${slug}&status=draft`
);

const draftData = await draftResponse.json();
Enter fullscreen mode Exit fullscreen mode

The rest of the logic is the same as in the initial slug route for published posts.

If we now head to /blog/draft/our-preview-post, we can access the draft post. However, it cannot be found on the /blog overview of all published posts. Unless you add a published version.

NOTE: /our-preview-post in the link above is the slug of the draft entry.

Adding a Draft Label

Currently, there is no way to tell if a post is a draft or published. We want to add a label to posts that aren't published yet to clearly mark them as a draft. Let's display a small label on draft posts to mark them as such.

This is the updated code:

<BlogLayout title={post.title}>
  <article class="prose prose-lg max-w-2xl mx-auto py-24">
    {
      headerImage && (
        <img
          src={`${import.meta.env.STRAPI_URL}${headerImage}`}
          alt={post.title}
          class="mb-6 w-full h-auto rounded-lg"
        />
      )
    }
    <div class="flex items-center justify-between mb-4">
      <h1 class="mb-0">{post.title}</h1>
      <p
        class="text-base font-semibold bg-yellow-100 text-yellow-800 px-3 py-1 rounded flex-shrink-0"
      >
        Draft
      </p>
    </div>
    <div set:html={marked.parse(post.content)} />
  </article>
</BlogLayout>
Enter fullscreen mode Exit fullscreen mode

If we now access the draft post, it is going to have a label that marks it as a draft.

add draft label.png

Publish, Unpublish, and Discard Actions

For the following section, we'll leverage the Strapi Document Service API to build a password-protected Publish, Unpublish, and Draft Discard action. On every article, we will show buttons to either publish a draft or unpublish an article, as well as a discard button for drafts. Since these actions are sensitive, only users who know a secret password will be able to perform them.

We are working with a separate frontend (Astro Site) and backend (Strapi CMS), all communications go through the REST API of our Strapi instance. By default, it doesn't support the actions we want to implement. However, Strapi offers unlimited customization by writing your own code.

Create Document Actions

To get started, open up the previously created Strapi instance in your code editor. The things we have configured in the Strapi UI already created all files for our Post model. We will now modify these files. First, open up the src/api/post/services/post.js file and write the following code:

// src/api/post/services/post.js

'use strict';

/**
 * post service
 */

const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService('api::post.post', ({ strapi }) => ({
  // Extend core service with custom document management functions

  async publishDocument(id) {
    return await strapi.documents('api::post.post').publish({
      documentId: id,
    });
  },

  async unpublishDocument(id) {
    return await strapi.documents('api::post.post').unpublish({
      documentId: id,
    });
  },

  async discardDraftDocument(id) {
    return await strapi.documents('api::post.post').discardDraft({
      documentId: id,
    });
  },
}));
Enter fullscreen mode Exit fullscreen mode

This defines the three document actions we want to implement.

Create Custom Controller

We now need to call them in the controller at src/api/post/controllers/post.js:

// src/api/post/controllers/post.js

'use strict';

/**
 * post controller
 */

const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::post.post', ({ strapi }) => ({
  async publish(ctx) {
    try {
      const { id } = ctx.params;
      const result = await strapi.service('api::post.post').publishDocument(id);
      ctx.send(result);
    } catch (err) {
      ctx.throw(500, err);
    }
  },


  async unpublish(ctx) {
    try {
        console.log('hello hello hello')
      const { id } = ctx.params;
      const result = await strapi.service('api::post.post').unpublishDocument(id);
      ctx.send(result);
    } catch (err) {
      ctx.throw(500, err);
    }
  },


  async discard(ctx) {
    try {
      const { id } = ctx.params;
      const result = await strapi.service('api::post.post').discardDraftDocument(id);
      ctx.send(result);
    } catch (err) {
      ctx.throw(500, err);
    }
  },
}));
Enter fullscreen mode Exit fullscreen mode

Create Custom Routes

And lastly, create a new file src/api/post/routes/post-actions.js to define the new API routes for these actions:

// src/api/post/routes/post-actions.js
module.exports = {
  routes: [
    {
      method: "POST",
      path: "/posts/:id/publish",
      handler: "post.publish",
      config: {
        auth: {
          strategies: ["api-token"],
        },
      },
    },
    {
      method: "POST",
      path: "/posts/:id/unpublish",
      handler: "post.unpublish",
      config: {
        auth: {
          strategies: ["api-token"],
        },
      },
    },
    {
      method: "DELETE",
      path: "/posts/:id/discard",
      handler: "post.discard",
      config: {
        auth: {
          strategies: ["api-token"],
        },
      },
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

Create Strapi API Token for publish(), discard() and discardDraft

As you can see, we have defined the strategy as api-token. This means those endpoints can only be called with a valid Bearer Token. So, let's create this token in the UI.

Go to Settings > API Token and click Create new API Token.

create api token.png

Name the token whatever you like and choose the token duration you want. The important part is the permissions. If you've followed all the steps, you should see the three actions we've added in the Permissions section. Only select these three since it's best practice to provision tokens with as little access as needed. With that, create the token and copy it.

enable permision for api token.png

Head back to your Astro codebase and open the .env file. Paste in the token as STRAPI_TOKEN. While you're at it, also define a SECRET variable and set whatever value you like. We will use this as the password to execute these actions in the frontend.

Your Astro environment file .env should now look like this:

STRAPI_URL=http://localhost:1337
STRAPI_TOKEN=your-token
SECRET=your-secret
Enter fullscreen mode Exit fullscreen mode

Create Astro API Endpoints

We cannot call our Strapi API directly from the frontend, since it requires a secret token. We can only store that token in code that runs on the server. Otherwise, we risk exposing it to all users. Therefore, we need a very simple server endpoint. Luckily, Astro supports that out of the box.

Create a new file in the Astro project at src/pages/api/content.js with this content:

export const prerender = false;

const STRAPI_URL = import.meta.env.STRAPI_URL;
const STRAPI_TOKEN = import.meta.env.STRAPI_TOKEN;
const SECRET = import.meta.env.SECRET;

export const POST = async ({ request }) => {
  if (request.headers.get("Content-Type") === "application/json") {
    const body = await request.json();
    const { action, postId, secret } = body;

    if (!action || !postId || !secret) {
      return new Response(
        JSON.stringify({ error: "Missing action, postId, or secret" }),
        { status: 400 }
      );
    }

    if (secret !== SECRET) {
      return new Response(JSON.stringify({ error: "Invalid secret" }), {
        status: 401,
      });
    }

    let endpoint = "";
    switch (action) {
      case "publish":
        endpoint = `${STRAPI_URL}/api/posts/${postId}/publish`;
        break;
      case "unpublish":
        endpoint = `${STRAPI_URL}/api/posts/${postId}/unpublish`;
        break;
      case "discard":
        endpoint = `${STRAPI_URL}/api/posts/${postId}/discard`;
        break;
      default:
        return new Response(JSON.stringify({ error: "Invalid action" }), {
          status: 400,
        });
    }

    try {
      const strapiResponse = await fetch(endpoint, {
        method: action === "discard" ? "DELETE" : "POST",
        headers: {
          Authorization: `Bearer ${STRAPI_TOKEN}`,
          "Content-Type": "application/json",
        },
      });

      if (!strapiResponse.ok) {
        throw new Error(`Strapi API error: ${strapiResponse.statusText}`);
      }

      const data = await strapiResponse.json();
      return new Response(JSON.stringify(data), { status: 200 });
    } catch (error) {
      console.error("Error calling Strapi API:", error);
      return new Response(JSON.stringify({ error: "Internal server error" }), {
        status: 500,
      });
    }
  }

  return new Response(JSON.stringify({ error: "Invalid content type" }), {
    status: 400,
  });
};
Enter fullscreen mode Exit fullscreen mode

In this section, we check that the client has sent the correct password. If that’s the case, we forward the request to Strapi along with the secret API Token. You can think of it as a type of proxy.

Adding Client-Side Interactivity with React

Setting up React in Astro

Up to this point, the whole application has not had any client-side code. Everything was rendered on the server and then sent to the client as static HTML. But to send actions to our API, we need some JavaScript on the client.

Vanilla JavaScript would be all you need for this. But we want to use this opportunity to show Astro's React integration. If you feel adventurous, you can use Vanilla JavaScript or another library like Vue or Svelte.

So, let's install React. All that's needed is one Astro command:

npx astro add react
Enter fullscreen mode Exit fullscreen mode

Create Publish and Unpublish Buttons

Let's create the first component at src/components/Visibility.jsx with:

export default function Visibility({ postId, isDraft, slug }) {
  const handleVisibilityChange = async () => {
    const action = isDraft ? 'publish' : 'unpublish';
    const secret = prompt(`Please enter the secret to ${action} this post:`);

    if (!secret) {
      console.log(`${action.charAt(0).toUpperCase() + action.slice(1)} operation cancelled`);
      return;
    }

    try {
      const response = await fetch('/api/content', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          action: action,
          postId: postId,
          secret: secret,
        }),
      });

      if (!response.ok) {
        throw new Error(`Failed to ${action} post`);
      }

      const data = await response.json();
      console.log(`Post ${action}ed successfully`, data);
      // Redirect to the appropriate URL based on the new state
      const newPath = isDraft ? `/blog/${slug}` : `/blog/draft/${slug}`;
      window.location.href = newPath;
    } catch (error) {
      console.error(`Error ${action}ing post:`, error);
      alert(`Failed to ${action} post. Please try again.`);
    }
  };

  return (
    <button
      onClick={handleVisibilityChange}
      className={`px-4 py-2 ${
        isDraft
          ? 'bg-emerald-500 hover:bg-emerald-600 focus:ring-emerald-500'
          : 'bg-amber-500 hover:bg-amber-600 focus:ring-amber-500'
      } text-per-neutral-900 font-semibold rounded-md focus:outline-none focus:ring-2 focus:ring-opacity-50 transition-all duration-200 border border-per-neutral-200 shadow-sm hover:shadow-md`}
    >
      {isDraft ? 'Publish' : 'Unpublish'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

This component is a button that toggles the visibility of a blog post between published and draft states, requiring a secret for authentication and updating the server via an API call. It takes the ID of the post and whether it's published or a draft as props.

create publish and unpublish buttons.png
A Published entry can be unpublished, and vice versa.

Create Discard Button Component

Now create the second component at src/components/Discard.jsx with:

import React from 'react';

export default function Discard({ postId }) {
  const handleDiscard = async () => {
    // Prompt the user for the secret
    const secret = prompt("Please enter the secret to discard this post:");

    // If the user cancels the prompt or enters an empty string, abort the operation
    if (!secret) {
      console.log('Discard operation cancelled');
      return;
    }

    try {
      const response = await fetch('/api/content', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          action: 'discard',
          postId: postId,
          secret: secret,
        }),
      });

      if (!response.ok) {
        throw new Error('Failed to discard post');
      }

      const data = await response.json();
      // Handle successful discard
      console.log('Post discarded successfully', data);
      // You might want to trigger some UI update here
    } catch (error) {
      console.error('Error discarding post:', error);
      // Handle error (e.g., show an error message to the user)
      alert('Failed to discard post. Please try again.');
    }
  };

  return (
    <button
      onClick={handleDiscard}
      className="px-4 py-2 bg-rose-500 text-per-neutral-900 font-semibold rounded-md hover:bg-rose-600 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-opacity-50 transition-all duration-200 border border-per-neutral-200 shadow-sm hover:shadow-md"
    >
      Discard
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

Since draft entries can be discarded, this component renders a "Discard" button that, when clicked, prompts the user for a secret and sends a request to discard a post, handling the response and potential errors.

Adding the Components to Our Pages

In src/pages/blog/[slug].astro, add the Visibility button into your markup:

<BlogLayout title={post.title}>
  <article class="prose prose-lg max-w-2xl mx-auto py-24">
    {
      headerImage && (
        <img
          src={`${import.meta.env.STRAPI_URL}${headerImage}`}
          alt={post.title}
          class="mb-6 w-full h-auto rounded-lg"
        />
      )
    }
    <div class="flex items-center justify-between mb-4">
      <h1 class="mb-0">{post.title}</h1>
    </div>
    <div class="mb-6">
      <Visibility client:load postId={post.documentId} isDraft={false} slug={slug} />
    </div>
    <p class="text-per-neutral-900 mt-2">{publishDate}</p>
    <div set:html={marked.parse(post.content)} />
  </article>
</BlogLayout>
Enter fullscreen mode Exit fullscreen mode

Observe the client:load directive. This tells Astro to ship the JavaScript of this component to the client. This is essential for performing the API call. If you remove it, the button will still be rendered - without any of the functionality.

In src/pages/blog/draft/[slug].astro, do the same thing, but this time with the Discard component as well:


<BlogLayout title={post.title}>
  <article class="prose prose-lg max-w-2xl mx-auto py-24">
    {
      headerImage && (
        <img
          src={`${import.meta.env.STRAPI_URL}${headerImage}`}
          alt={post.title}
          class="mb-6 w-full h-auto rounded-lg"
        />
      )
    }
    <div class="flex items-center justify-between mb-4">
      <h1 class="mb-0">{post.title}</h1>
      <p
        class="text-base font-semibold bg-yellow-100 text-yellow-800 px-3 py-1 rounded flex-shrink-0"
      >
        Draft
      </p>
    </div>
    <div class="flex gap-2 mb-6">
      <Visibility client:load postId={post.documentId} isDraft={true} slug={slug} />
      <Discard client:load postId={post.documentId} />
    </div>
    <div set:html={marked.parse(post.content)} />
  </article>
</BlogLayout>
Enter fullscreen mode Exit fullscreen mode

App Demo

Publish a Draft

Create a draft in the Strapi admin. Make sure you know the link of this draft, since you only want the link to a draft to be accessible to you alone. Remember, only click Save and do not click Publish since this should be a draft.

publish a draft.png

Next, visit the draft and publish it. Recall that for this to work, you will use your secret key.

Publish a Draft

When we check the Strapi admin panel, we will see that the draft post is published.

published draft.png

Unpublish A Published Post

Now that we have published this draft let's unpublish it.

Unpublish an article

This is what we will see when we visit the Strapi admin panel.

unpublished draft.png

Discard Draft

Go ahead and discard the draft. If successful, we will see this in the Strapi admin dashboard.

discarded draft.png

As you can see, the draft has been discarded and is no longer in the Strapi CMS admin dashboard.

Conclusion

In this tutorial, we've covered how to build a Blog Post Preview feature using Strapi 5 Draft and Publish feature along with Astro. This setup allows you to manage draft and published blog posts efficiently. Thanks to Astro, the website remains fast, and SEO-friendly and Strapi's Draft and Publish feature ensures a smooth content review process.

Using Strapi's Document Service API, we can control the visibility of posts directly from the Frontend. It remains secure because all requests are password protected through Astro's API endpoint.

You can find the complete code in this GitHub repository. Do checkout Strapi docs and Astro docs to learn more about building websites using Astro and Strapi.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .