Join me as I guide you through the journey of building a sleek, modern blog using Astro, styling it with Tailwind CSS, and deploying it effortlessly with Cloudflare Pages.
Github Repo: https://github.com/kungfoome/astro-blog-tutorial
Github Branch: baseline
In this article, I want to explain the process of how I created my blog site, which is still a work in progress. When I created my blog site, I started from an empty canvas and just started to add what I needed, when I needed. Astro has a nice tutorial on how create a blog yourself. I want to add some additional thoughts and extend that tutorial to include some more advanced features that would be helpful.
This article will cover how to get started with Astro and will somewhat walk you through that process step-by-step. This is the first article in a series of tutorials and this will lay out the foundation for us to build other features on top of this one.
Astro’s minimalistic approach allows us to build lightning-fast websites with significantly less client-side JavaScript. This offers a good balance between performance and developer experience. Tailwind CSS brings a utility-first approach to styling, allowing us to craft responsive designs with ease. Finally, Cloudflare Pages offers an easy and painless way to deploy our app, ensuring our website is not only fast but also globally distributed for optimal performance.
Whether you’re a seasoned developer or just starting, this tutorial aims to demystify the process of creating a feature-rich, high-performance blog from scratch. We’ll walk through every step – from setting up our development environment to deploying our final product – ensuring that you gain practical skills and a deeper understanding of these powerful tools. So, let’s get started and transform your ideas into a fully-functional, beautifully styled, and efficiently deployed website!
Features:
You should be familiar with HTML, CSS, as well as some terminology and the landscape of modern web frameworks like React. You should know how Tailwind works and understand what the styling means.
For this reason, this article is for someone that is in between a Beginner and Intermediate level for web development.
With the current state of web development, there are other tools you need to use that you may not know about. I will explicitly list every tool or library that we will work with as we develop this site. You may not need to manually install every tool that is listed as they are built into Astro already, so only install the packages when noted in the tutorial. More information about each tool and why we need them will be given throughout this article series.
node
within your terminal and bring up a REPL to run actual JavaScript code.Astro is a modern static site generator that allows you to build faster websites with less client-side JavaScript. To get started with Astro, you will need to have Node.js installed on your machine. Then, you can set up a new Astro project by running:
pnpm create astro@latest
This command will create a new directory with a basic Astro project structure.
Navigate into your new project folder and start the development server:
cd my-astro-project
pnpm install
pnpm dev
These are the files that should have been generated for you if you are using the options specified above.
/
├── node_modules/
├── public/
├── src/
│ ├── pages/
│ | └── index.astro
| └── env.d.ts
├── .gitignore
├── package.json
├── pnpm-lock.yaml
└── tsconfig.json
src
folder instead.pnpm
or any other Javascript package manager. You should not have to modify anything in this folder. You also do not want to check this into source control as it can grow to GBs in size.pages
folder. This is the file that is currently Rendering the Astro text. We will be modifying this over time to make it more dynamic.env.d.ts
file.pnpm dev
and setup dependencies. You may do some slight modifications manually, but you can also use a package manager like pnpm
to add additional dependencies.pnpm
and should not be modified directly. This will create a package lock, so that you will always download the correct versions of the packages that you used and only update the packages when you tell pnpm
to update those packages. This helps if there are any breaking changes in the packages you are using.Tailwind will be used to style our HTML elements. We need to install this now, as we will be using this throughout the article. You do not have to use Tailwind and instead you can use normal CSS if you would like.
pnpm astro add tailwind
This will create a tailwind.config.js
file in your project. Astro will automatically generate this file for you:
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {}
},
plugins: []
};
Astro will also update the astro config file:
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()]
});
You might have seen in other articles where there is a step to add the base tailwind directives:
@tailwind base;
@tailwind components;
@tailwind utilities;
For Astro, we do not need to do this, as these files are automatically added by default. You can configure Astro to exclude the base directives if you would like, but we will keep them.
If you look at the page now using pnpm dev
, you can see the font has slightly changed from before.
Astro introduces the concept of layouts. While these files do not have to be placed in the layouts/ folder, that is what we will do here. We are just going to start with a very basic layout that looks like this:
┌────────────┐
│ Header │
├────────────┤
│ │
│ Main │
│ │
└────────────┘
Files we need to create:
/
└── src/
├── layouts/
| └── BaseLayout.astro
└── pages/
└── index.astro
Create a src/layouts/
folder.
Create a new file called BaseLayout.astro
.
Add the follow code into BaseLayout.astro
:
---
const { pageTitle } = Astro.props;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
</head>
<body>
<slot />
</body>
</html>
Update the index.astro
file to:
---
import BaseLayout from '../layouts/BaseLayout.astro';
const indexPageTitle = 'My Blog';
---
<BaseLayout pageTitle={indexPageTitle}>
<span class="text-5xl">Astro</span>
</BaseLayout>
The new BaseLayout.astro
file looks very similar to what index.astro
started off with. There are a few changes to pay special attention to:
pageTitle
. We can now use this as variable within our HTML template.<slot />
. This is how Astro knows where we can add content that is specified within our layout component. More on this in the index.astro
change explanation.Changes in the index.astro
file include:
BaseLayout
as a component to use within the index file. We then use this to generate the HTML using <BaseLayout></BaseLayout>
.BaseLayout.astro
file, we unpacked that variable from Astro.props
. THis is way in which we can pass parameters around to different components.Astro
inside the BaseLayout
component. This is what will replace the <slot />
element insode the BaseLayout.astro
file.Astro
to be font-size: 3rem; /* 48px */
and line-height: 1;
. Tailwind does this under the hood for us.Now we have a base layout that we can use throughout the entire site. This will make it consistent and reusable across different pages. If you decided to add an About page, then you can reuse this layout and just change the content in the main section.
With tailwind, you can look at pre-built components, get the html, and paste that html into your code. That is what we will do now to create a header. We will start with this one. Whenever you are looking for components to add, you will want to select the option for HTML and copy that code.
Files we need to create:
/
└── src/
└── components/
└── Header.astro
Create a src/components/
folder.
Create a new file called Header.astro
.
Add the follow code into Header.astro
:
---
---
<nav class="bg-gray-800">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between">
<div class="flex">
<div class="flex flex-shrink-0 items-center">
<a href="/" class="flex">
<img
class="h-8 w-auto"
src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=500"
alt="Your Company"
/>
</a>
</div>
</div>
<!-- Posts Button Here -->
<div class="flex items-center">
<div
class="hidden md:ml-4 md:flex md:flex-shrink-0 md:items-center"
>
<button
type="button"
class="relative rounded-full bg-gray-800 p-1 text-gray-400 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800"
>
<span class="absolute -inset-1.5"></span>
<span class="sr-only">View notifications</span>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
></path>
</svg>
</button>
</div>
</div>
</div>
</div>
</nav>
If you want to know more about what each class means, you will need to look at the Tailwind documentation. Just as an example, you can look up max-w-7xl
and it would lead you here: https://tailwindcss.com/docs/max-width.
Import the Header component to the BaseLayout.astro
file:
---
import Header from '../components/Header.astro';
const { pageTitle } = Astro.props;
---
Specify where to use the Header component to the BaseLayout.astro
file and place it right above the slot:
<body>
<Header />
<slot />
</body>
We now have a new Header component that is also reusable. If you wanted to, you can could take the HTML from the header component and just paste that directly into the BaseLayout.astro
. By creating separate components, it’s just easier to visualize your overall layout.
You will now see a blue box at the top of the page and a main section with the text Astro
.
From here, you should start playing with layouts and styling. Do you want to add additional links? Now would be a good time to start experimenting on how to style your header and add what you want. You can also change the colors and styling based on Tailwind CSS.
By default, you can place markdown files under the pages/
directory and Astro will automatically convert these files for you. This is part of their tutorial, but we are going to skip over that and jump straight into collections. A collection is a collection of pages that are alike. Astro has some internal APIs that you can use, which makes it easier to dynamically work with a collection of pages. In this case, we will have a collection of blog posts.
Files we need to create:
/
└── src/
├── content/
| ├── posts/
| └── config.ts
└── schemas
└── PostSchema.ts
Create the src/content/posts/
folder. This is a special folder. Astro will automatically create a collection out any pages that are added to this folder. We are calling the collection posts
in this case.
Create the src/schemas/
folder. We can add any schemas we want in this folder. You can add the schemas directly in the config.ts
file that we will create next. I am showing you how to do it this way, so we can easily how we can keep the schemas seperated, which makes it easier to read and scan then config.ts
file.
Create a new file called BlogSchema.ts
under src/schemas/
folder with the content:
import { z, type SchemaContext } from 'astro:content';
export const imageSchema = ({ image }: SchemaContext) =>
z.object({
src: image(),
alt: z.string(),
});
export const postSchema = ({ image }: { image: any }) => z.object({
title: z.string(),
description: z.string().optional(),
publishedDate: z.string().or(z.date()).transform((str) => new Date(str)),
featured: z.boolean().default(false),
draft: z.boolean().default(true),
postimage: imageSchema({ image }).optional(),
});
Create a config.ts
file in the src/content/
folder. We will use this file to create new collections and let Astro know how to validate them. The content of that file should look like:
import { defineCollection } from 'astro:content';
import { postSchema } from '../schemas/PostSchema';
const postCollection = defineCollection({
type: 'content',
schema: postSchema
});
export const collections = {
posts: postCollection
};
We created a new schema for our blog posts. This creates an object that we can use to validate if the metadata of the blog post is correct. Astro uses Front Matter to define metadata about the markdown file itself. Think of this as our database and we will use the Astro API to query this database later. We build this schema using the Zod library under the hood. I wanted to show some of the capabilties on the image processor in Astro, so we have a property for an image that we will also user later on. Hopefully this is pretty self explanatory in that you can use chaining to define how a parameter should validate. For more information, you can look at the Astro and Zod documentation. The only thing that is a bit special is the image
parameter being passed in. We will see why this is important later on.
We created a collection and told Astro that we have a new collection. We specified that in the special file src/content/config.ts
. You can see we how we import the Schema and then used that create a new collection. We define the collection in export const collections = {
the name of the collection needs to match the folder src/content/posts/
. You can add other collections by creating a new folder and schema and then adding it in the config just like we did here.
We now have a working Collection. We can verify this by creating a test markdown file and seeing if we can retrieve it.
Files we need to create:
/
└── src/
├── content/
| └── posts/
| └── firstpost.md
└── pages
└── postscollection.astro
Create a dummy markdown file called firstpost.md
to use for testing in src/content/posts/
with the content:
---
title: Second blog post
description: 'Second blog post to try out images'
publishedDate: 2022-07-08
featured: true
draft: false
postimage:
src: '../../assets/firstpost/firstpostimage.png'
alt: 'My Image'
---
After a successful first week learning Astro, I decided to try some more. I wrote and imported a small component from memory!
Create a debug file called postscollection.astro
in src/pages/
with the contents of:
---
import { Debug } from 'astro:components';
import { getCollection } from 'astro:content';
// Fetch collection
const posts = await getCollection('posts');
---
<h1>Posts Collection</h1>
<Debug {posts} />
Browse to http://localhost:4321/postscollection. If the collection is working, you will now see a list of the all the pages under the src/content/posts/
folder. In this case, we only have the 1 post, but you can add more and you will see them populate.
We created a dummy markdown file with the required metadata.
Go ahead and try to create a markdown file where you are missing a required field. Return to the debug page and see what error you get.
You can now see that the schema is working to validate front matter data and that we can use Astro’s API to return a collection. You can sort of see how this can be used as a database in a way.
We created a Debug page. This page can be called anything you would like to call it. We are just using this page to make sure we can call the getCollection
API. We will be using this same code to create pages for all of our Markdown files and properly render them.
We just need a way to render our markdown pages into HTML and see them on a page. Just like before, we setup a BaseLayout to define what our website should look like globally. We are now going to create a Layout to define how we want the pages in the posts
collection to look like.
Files we need to create:
/
└── src/
├── assets/
| └── firstpost/
| └── firstpostimage.png
├── layouts/
| └── MarkdownPostLayout.astro
└── pages/
└── post/
└── [...slug].astro
Go on the internet and just find any image that is a png. Save or move this file into src/assets/firstpost/
and name it firstpostimage.png
. If you name it something different, just make sure that the path for postimage.src
in the firstpost.md
file matches the image that you saved.
Install the sharp
package as this will be used later to process and manipulate images:
pnpm install sharp
Create a file named MarkdownPostLayout.astro
in the src/layouts/
folder with the following contents:
---
import type { CollectionEntry } from 'astro:content';
import { Image } from 'astro:assets';
import BaseLayout from './BaseLayout.astro';
interface Props {
post: CollectionEntry<'posts'>;
}
const { post } = Astro.props;
---
<BaseLayout pageTitle={post.data.title}>
<div class="py-8 sm:py-16">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<Image
src={post.data.postimage!.src}
alt={post.data.postimage!.alt}
width="200"
height="200"
/>
<span class="flex text-3xl">{post.data.title}</span>
<span
>Published: {
new Date(post.data.publishedDate).toLocaleDateString(
'en-US'
)
}</span
>
<p class="my-6 text-lg leading-8">
{post.data.description}
</p>
<div class="mt-10">
<slot />
</div>
</div>
</div>
</BaseLayout>
Create a new folder src/pages/post
.
Create a file named [...slug].astro
in the src/pages/post/
folder with the following contents:
---
import { getCollection } from 'astro:content';
import MarkdownPostLayout from '../../layouts/MarkdownPostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post }
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<MarkdownPostLayout post={post}>
<Content />
</MarkdownPostLayout>
We added an image that will go with out firstpost
. More on this later.
We installed the sharp
package. More on this later.
We created a new layout called MarkdownPostLayout.astro
to render our markdown and embed that into some HTML.
We grab the schema from what a post should look like. You do not have to do this and you can unpack variables like normal and reference them as front matter properties. We do this because it is easier to reference what is available to use within our IDE. We can use autocompletion to quickly set the fields where we want.
We import the BaseLayout
and wrap it around a <slot />
element. Remember, when we use this layout as component, anything inside the component element will replace the <slot \>
element. More on this when we talk about the [..slug].astro
page.
The post.data.postimage!.src
has a null check there. We are ignoring this since in our schema validation, this is a required field and it is not optional. We know this can never be null. You could explicity check if it is null and then render the Image
component like so:
{
post.data.postimage?.src && (
<Image
src={post.data.postimage.src}
alt={post.data.postimage.alt}
width="200"
height="200"
format="png"
></Image>
);
}
Everything else is just styling and additional information that we want to show on the post’s page. With this, we still have the styled header, just like on the home page and we have a main section to put our content into. We do a little formatting to make it look somewhat decent.
We created a [..slug].astro
under the src/pages/post/
folder.
Remember that the pages/
folder is a special folder that defines a route. In this case the post
folder creates a route to /post/[slug]
. Where slug is the slug name of the post. The slug name is the name of the file that has been converted to remove special characters and spaces. For our example, this would be firstpost
and the complete path would be mysite.com/post/firstpost
.
The [..slug]
name is a special name for Astro. There are many ways to create routes in Astro and you should read the documentation to further explore what those options are and how you want to do it. By using slug, we are generating a path based on the src/contents/
structure.
You can try this out by creating a new folder src/contents/posts/2024/ and then copying and moving the firstpost.md file into the 2024/ folder. Make sure you update the relative image location to ../../../assets/firstpost/firstpostimage.png. Browse to http://localhost:4321/postscollection and look at the slug field
The URL now turns into mysite.com/post/2024/firstpost
.
Speaking of the debug component. Remember how we queried all the posts within the collection. We do that here as well. The only difference is that we need to tell Astro how to create the routes by using the getStaticPaths
function. This is how we are able to browse to the individual posts.
The last thing is that we need to grab the markdown content and render it. We do that by using post.render()
. This will use the library of your choice (default is remark) to take markdown code and convert it to HTML. We then pass that into the MarkdownPostLayout
component using the new Content
component that holds the markdown HTML. This HTML replaces the <slot />
element that we specified inside the MarkdownPostLayout
file.
At this point, we can now create as many markdown files as we want and place them in the src/content/posts/
folder. Astro will generate all the static files for us and create the necassary routes. We can now access our firstpost.md
by going to http://localhost:4321/post/firstpost. We can do a pnpm build
and everything should be building without errors and we can see the files being generated in the dist/
folder. Astro generated Typescript types for us, which are located in the .astro
folder. If you were to look at the types in there, you can see some things that reference the post
schema. This allows our IDE to know the structure of our data. We are basically done with the basics of this site. We still cannot easily see all the blog posts that exist, so let’s do that next.
Before continuing, see if you can create a new page to list and link to the posts. You know how to get a collection of posts in the postscollection.astro page. You know how to style a page using the BaseLayout. You know how to create new pages under the src/pages/ folder. Link to the posts using this href format: /post/${post.slug}.
/
└── src/
└── pages/
└── posts.astro
Create a new page src/pages/posts.astro
with the following contents:
---
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
const publishedPosts = await getCollection('posts', ({ data }) => {
return data.draft !== true;
});
const sortedPosts = publishedPosts.sort(
(a, b) =>
b.data.publishedDate.valueOf() - a.data.publishedDate.valueOf()
);
---
<BaseLayout pageTitle="List of Posts">
<div class="mx-auto mt-10 max-w-7xl px-6 lg:px-8">
<ul>
{
sortedPosts.map((post) => (
<li class="list-disc">
<a href={`/post/${post.slug}`}>{post.data.title}</a>
</li>
))
}
</ul>
</div>
</BaseLayout>
Modify src/components/Header.astro
and replace <!-- Posts Button Here -->
with:
<div class="flex items-center">
<a
href="/posts"
class="rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-800 "
>Posts</a
>
</div>
This should go right after the company logo and before the notification button.
We created a button within the header to navigate to the page /posts
. This will allows to quickly get to that page from the home page and see a list of all the posts.
We created a new page called posts
. In this page we:
Get the collection of all posts. We then filter all posts that are in the published state, meaning that if the draft
parameter in the markdown file is set to false, we will retrieve that post. Now if you did this on your own, you may have not done any sort of filtering and that is ok. I just wanted to show how you can do that if you wanted to.
We then sorted the posts in decending order to have the latest post first.
From there we simply map over the filtered and sorted posts and render it as HTML. We also reused our BaseLayout
to create that header and main section.
As I mentioned before, you can run pnpm build
to build the static assets. These go into the dist/
folder. Remember the Image component we used? This is where it comes in handy. If we use that component and when we build, sharp
will convert that image to what we told it to. In this case we want an image that is 200 x 200 pixels. In this example, I used a large image and when I build the site, it reduces the image size down significantly:
19:26:54 ▶ /_astro/firstpostimage.dH7VHyWq_1N46Q2.png (before: 1029kB, after: 79kB) (+63ms) (1/1)
You can see we went from a 1MB file down to an 80kb file. In future articles, we will extend this even further and create picture sets to show the same image with different sizes depending on if the device is mobile or desktop.
If the build ran successfully, then we can deploy the app to Cloudflare pages.
I am not going to explain every step in detail here, but simply outline the basic steps.
Push Your Code to GitHub: Ensure your project is pushed to a GitHub repository (this can be private).
Login to Cloudflare.
Go to Workers and Pages.
Go to Create application.
Select the Pages tab.
Select connect to Git.
Select the GitHub repository you want to deploy and click Begin setup.
Give your project a name. Select the main branch. Set the build command to pnpm build
and the build output directory to dist/
.
Click save and Deploy.
Once the build is complete and the site is deployed, you should be able to access the site using the unique URL that cloudflare gave to you. This should be based on the project name. Something like https://<project_name>.pages.dev.
Feel free to open the developer console (F12) in Chrome and run the lighthouse test. You may need to do this in a private window if you have a lot of chrome plugins. You can see the optimization and a score for SEO purposes.
I heard a lot of great things about Astro. I wanted to try it out myself. As I am not primarily a frontend developer, I was looking for something a bit simpler than React. I think Astro checks that box for me. It was easy to use and setup and I am enjoying my time using Astro. It was easy to get started. The initial files that it generates is pretty minimal compared to other frameworks. As I start to add more stuff and go beyond static site, I will look into using HTMX. I can see why Astro paired with HTMX can be a match made in heaven.
I really like using Tailwind. I think it is easier than trying to create classes and then adding styles to those classes. It is easier to just see the styling in my HTML code as it makes it easier to debug. I also do not have to worry about removing unneeded styles, which reduces the total size of my CSS files. Tailwind will only include the classes that you use. It is less thinking and more doing.
I still have a lot left to do as far as styling and functionality goes. I am excited to continue down this adventure and hammer this out until I am happy with the final outcome.
This is just the first step in developing the blog site.. It is a very similar process that I used when building my own site. I will continue to add additional features to my blog and this means I will continue to add additional blog posts as a series of posts building on top of this tutorial. Hopefully this was helpful in understanding some of the key features of Astro and you learned something new.