Build a Blog Post Preview with Strapi Draft and Publish and Astro
In this tutorial, we'll build a Blog Post Preview feature using Strapi 5 Draft and Publish feature along with Astro. We will create an API endpoint in our Astro project that allows us to control the visibility of posts directly from the frontend. 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. To follow this tutorial, you need: 1. Node.js installed on your machine (version 14 or higher). 2. An understanding of JavaScript and React basics. 3. A basic knowledge of Astro and Strapi. Let's get started! First, let's create a new project using the following command: ```bash npx astro create strapi-draft-publish-feature ``` This will create a new directory named `strapi-draft-publish-feature`. Navigate into this directory and install Strapi CLI globally: ```bash cd strapi-draft-publish-feature npm i -g @strapi/cli ``` Now, let's create a new Strapi project inside our Astro project. Run the following command in your terminal: ```bash npx create-strapi-app api --quickstart ``` This will prompt you to choose some options for setting up your Strapi project. Choose any name and description you like, then select "Node.js" as the package manager and "SQLite" as the database. Once the installation is complete, start both Astro and Strapi servers: ```bash npm run dev ``` Now that we have our development environments set up, let's create a new Content-Type in Strapi called "Post". Go to Content-Types Builder > Create New Content Type > Add another Content-Type. Name it "Post" and click on the "Continue" button. In the next screen, add the following fields: 1. Title (Text) 2. Content (Rich Text) 3. Header Image (Media) 4. Publish Date (Date & Time) 5. Draft (Boolean) Save and publish these changes. Now you should see a new Post entity in your Strapi admin panel under the "Content" section. Next, let's create an API route for handling our actions on posts. In the terminal, navigate to the `strapi-draft-publish-feature` directory and run: ```bash npm install @strapi/[email protected] strapi-plugin-documentation strapi-plugin-upload strapi-plugin-content-manager [email protected] strapi-utils ``` This will install the necessary plugins and utilities needed for our API route. Now, create a new file at `src/api/post/controllers/post.js` with this content: ```javascript const { sanitizeEntity } = require('strapi-utils'); module.exports = { async publish(ctx) { try { const { id } = ctx.params; const result = await strapi.service('api::post.post').publishPostDocument(id); ctx.send(result); } catch (err) { ctx.throw(500, err); } }, async unpublish(ctx) { try { const { id } = ctx.params; const result = await strapi.service('api::post.post').unpublishPostDocument(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); } }, }; ``` And lastly, create a new file at `src/api/post/routes/post-actions.js` to define the new API routes for these actions: ```javascript // 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"], }, }, }, ], }; ``` 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. 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. 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: ```bash STRAPI_URL=http://localhost:1337 STRAPI_TOKEN=your-token SECRET=your-secret ``` 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 at `src/pages/api/content.js` with this content: ```javascript 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", }, body: JSON.stringify({}), }); 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, }); }; ``` 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. 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: ```bash npx astro add react ``` Let's create the first component at `src/components/Visibility.jsx` with: ```javascript import React from 'react'; 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> ); } ``` 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. A Published entry can be unpublished, and vice versa. Now create the second component at `src/components/Discard.jsx` with: ```javascript 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> ); }; ``` 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. In `src/pages/blog/[slug].astro`, add the Visibility button into your markup: ```javascript <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> ``` 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: ```javascript <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="mb-6"> <Discard client:load postId={post.documentId} /> </div> <p class="text-per-neutral-900 mt-2">{publishDate}</p> <div set:html={marked.parse(post.content)} /> </article> </BlogLayout> ``` Now, let's test our implementation by creating a new post in Strapi and trying out the visibility buttons on the frontend. You should see that when you click on "Publish" or "Unpublish", the corresponding action is performed on the backend, and the page reloads to reflect the updated state of the post. That's it! We have successfully built a Blog Post Preview feature using Strapi 5 Draft and Publish feature along with Astro. You can now use this setup in your own projects to manage draft and published blog posts efficiently.
Company
Strapi
Date published
Oct. 10, 2024
Author(s)
Noah Falk
Word count
4750
Hacker News points
None found.
Language
English