Skip to content

Basic Blog

dsfghgjgjuyfth
· 7 min read
Copyright: MIT
blog project
astro tailwind js
Screenshot of the "dsfghgjgjuyfth" blog homepage featuring a dark UI and three featured articles with hand-drawn, sketch-style illustrations on a textured tan background.

Basic Blog is a mostly lightweight, high-performance blog template built with Astro 6.

Features

  • Astro 6+ Ready:
    • Native Font Optimization
    • Pre-configured CSP (Content Security Policy) and security headers (optimized for Cloudflare, easily adaptable for Vercel or Netlify).
  • Responsive: mobile, tablet, and desktop devices.
  • Stack: Astro, Tailwind, and JS.

Deployment

You will find a _headers file located in the /public folder. This file handles the Content Security Policy (CSP) and XSS protections mainly for Cloudflare.

If you are deploying to Vercel or Netlify, simply move these rules to a vercel.json or netlify.toml file respectively.

Configuration

Environment Variables

For forms to work, you must add your Turnstile keys to your .env file:

Terminal window
PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name_here
PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAA...

Site Settings & SEO

Global settings, SEO metadata, and navigation links are managed in src/site.config.ts.

Update SITE_SETTINGS to change your site title, description, and social media handles:

export const SITE_SETTINGS = {
title: "dsfghgjgjuyfth site",
description: "A space to explore ideas and share what I'm learning",
owner: "dsfghgjgjuyfth",
ogImages:
"https://dsfghgjgjuyfth-57301d31.pages.dev/images/placeholder-card.svg",
socials: [
{
icon: "github",
label: "GitHub",
url: "https://github.com/dsfghgjgjuyfth-57301d31",
handle: "dsfghgjgjuyfth-57301d31",
},
],
};

You can easily manage your site’s menu structure by editing the header and footer exports:

export const header = [
{
name: "Blog",
url: "/blog",
},
{
name: "Projects",
url: "/projects",
},
{
name: "About",
url: "/about",
},
{
name: "Contact",
url: "/contact",
},
{
name: "Newsletter",
url: "/newsletter",
},
];
export const footer = [
{
title: "Content",
links: [
{
name: "Search",
url: "/search",
},
{
name: "Blog",
url: "/blog",
},
{
name: "Projects",
url: "/projects",
},
{
name: "About",
url: "/about",
},
],
},
{
title: "Resources",
links: [
{
name: "Contact",
url: "/contact",
},
{
name: "Newsletter",
url: "/newsletter",
},
{
name: "RSS",
url: "/rss.xml",
},
{
name: "Sitemap",
url: "/sitemap-index.xml",
},
],
},
];

Metadata & OG Images

You can define custom Open Graph images for both static pages and posts.

For Static Pages

Pass the ogImage prop directly into the <BaseLayout> component:

<BaseLayout
title="Search"
description="Search articles and projects from dsfghgjgjuyfth."
ogImage="https://res.cloudinary.com/.../og-search.png"
>
</BaseLayout>

For Blog Posts

Add the image details to the Frontmatter of your Markdown files. The template will automatically use this for the post’s social sharing card:

image:
src: "https://res.cloudinary.com/.../how_to_build_trust.webp"
alt: "A hand-drawn sketch representing stability with labels: 'Know', 'Like', and 'Trust'."
ogImage:
src: "https://res.cloudinary.com/.../how_to_build_trust.webp"

Image Optimization

This template uses Cloudinary paired with LQIP (Low-Quality Image Placeholders) to ensure a smooth loading experience. When an image is requested, a blurred placeholder is shown until the full-resolution image is ready.

You can find the implementation logic in src/components/ui/OptimizedImage.astro:

<div
class={cn(
"relative overflow-hidden bg-neutral-200 dark:bg-neutral-700",
wrapperClass,
)}
>
{
lqipUrl && (
<img
data-pagefind-ignore
src={lqipUrl}
alt=""
aria-hidden="true"
class={cn(
"absolute inset-0 h-full w-full scale-105 object-cover blur-xl",
imageClass,
)}
loading="eager"
decoding="async"
/>
)
}
<CldImage
src={src}
alt={alt}
class={cn("absolute inset-0 h-full w-full object-cover", imageClass)}
loading={loading}
fetchpriority={fetchPriorityVal}
format="auto"
quality="auto"
{...rest}
/>
</div>

Fonts

Local fonts are stored in src/assets/fonts/. To add or change fonts:

  1. Drop your .woff2 files into the fonts folder.
  2. Update the astro.config.mjs font provider:
fonts: [
{
provider: fontProviders.local(),
name: "YourFontName",
cssVariable: "--font-custom",
options: {
variants: [{
src: ["./src/assets/fonts/YourFont.woff2"],
weight: "normal",
style: "normal",
}],
},
},
],
  1. Update the variable in src/styles/global.css:
font-family: var(--font-custom), system-ui, sans-serif;

For more info on using remote fonts (Google Fonts, etc.), check the Astro Font Provider Reference.

Forms & Bot Protection

The template uses Cloudflare Turnstile for non-intrusive bot protection and Astro Actions for server-side processing.

Newsletter Subscription

Handled via utils/client-newsletter.ts. It uses AJAX to submit the form without refreshing the page, providing instant feedback and resetting the Turnstile widget upon success.

Contact Form

Located in src/pages/contact.astro.

  • State Persistence: Includes a script that caches form data in sessionStorage. If the user refreshes or a validation fails, their message isn’t lost.
  • Success Handling: Automatically redirects to a success state and clears the cache once the message is sent.

Turnstile Implementation

Turnstile code is in src/components/integrations/turnstile/:

  • TurnstileWidget.astro: The UI component that renders the challenge.
  • TurnstileController.astro: Manages the lifecycle of the widget, including theme switching (light/dark) and automatic cleanup/re-initialization.

Server-Side Actions

To make the newsletter and contact form functional, you must:

  1. Uncomment export const prerender = false; in the respective page files (if using SSR).
  2. Create src/actions/index.ts to define your logic.

Implementation Example (Resend & Kit)

Note: I’m not a pro with this specific setup yet, so take this with a grain of salt.

src/actions/index.ts
import { ActionError, defineAction } from 'astro:actions';
import { z } from "astro/zod";
import { escapeHtml } from '@utils/escapeHtml';
import { stripCtl } from '@utils/stripCtl';
import { Resend } from 'resend';
const resend = new Resend(import.meta.env.RESEND_API_KEY);
const KIT_FORM_ID = import.meta.env.KIT_FORM_ID;
const KIT_API_KEY = import.meta.env.KIT_API_KEY;
const TURNSTILE_SECRET = import.meta.env.TURNSTILE_SECRET_KEY;
const turnstileField = import.meta.env.DEV
? z
.string()
.optional()
.default('') // dev: missing → ''
: z.string().min(1); // prod: must be non-empty
const fetchWithTimeout = async (url: string, init: RequestInit = {}, ms = 8000) => {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), ms);
return fetch(url, { ...init, signal: controller.signal }).finally(() => clearTimeout(t));
};
async function verifyTurnstile(token: string, remoteip?: string, expectAction?: string) {
if (import.meta.env.DEV) return true;
if (!TURNSTILE_SECRET) {
console.error('TURNSTILE_SECRET_KEY missing');
return false;
}
const body = new URLSearchParams({
secret: TURNSTILE_SECRET,
response: token,
...(remoteip ? { remoteip } : {}),
});
const res = await fetchWithTimeout('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
}).catch((err) => {
console.warn('Turnstile verify fetch failed/aborted', err);
return undefined;
});
if (!res || !res.ok) {
console.warn('Turnstile verify non-OK', res?.status);
return false;
}
type VerifyResp = {
success: boolean;
hostname?: string;
'error-codes'?: string[];
action?: string;
cdata?: string;
challenge_ts?: string;
};
const json = (await res.json().catch(() => ({}))) as VerifyResp;
if (!json.success) {
console.warn('Turnstile failed', json);
return false;
}
if (expectAction && json.action && json.action !== expectAction) {
console.warn('Unexpected action', json.action);
return false;
}
return true;
}
function getRemoteIp(request: Request): string | undefined {
const h = request.headers;
return (
h.get('CF-Connecting-IP') ??
h.get('x-real-ip') ??
h.get('x-forwarded-for')?.split(',')[0]?.trim() ??
undefined
);
}
export const server = {
contact: defineAction({
accept: 'form',
input: z.object({
name: z.string().min(1).max(100),
email: z.string().email().max(254),
message: z.string().min(1).max(5000),
'cf-turnstile-response': turnstileField,
}),
handler: async ({ name, email, message, 'cf-turnstile-response': token }, ctx) => {
const remoteip = getRemoteIp(ctx.request);
const ok = await verifyTurnstile(token, remoteip, 'contact');
if (!ok) {
throw new ActionError({ code: 'BAD_REQUEST', message: 'Turnstile verification failed.' });
}
const safeName = stripCtl(name);
const safeEmail = stripCtl(email);
const escapeName = escapeHtml(safeName);
const escapeEmail = escapeHtml(safeEmail);
const escapeMessage = escapeHtml(message).replace(/\n/g, '<br>');
try {
const { data, error } = await resend.emails.send({
from: 'Contact Form <contact@mail.dsfghgjgjuyfth-57301d31.com>',
to: ['dsfghgjgjuyfth-57301d31@gmail.com'],
subject: `New Contact Form Submission from ${safeName}`,
replyTo: `${safeName} <${safeEmail}>`,
html: `
<p>You have a new contact form submission:</p>
<p><strong>Name:</strong> ${escapeName}</p>
<p><strong>Email:</strong> <a href="mailto:${escapeEmail}">${escapeEmail}</a></p>
<p><strong>Message:</strong></p>
<p>${escapeMessage}</p>
<p style="color:#666">Sent from dsfghgjgjuyfth-57301d31.com</p>
`,
text:
'You have a new contact form submission:\n\n' +
`Name: ${safeName}\n` +
`Email: ${safeEmail}\n\n` +
`Message:\n${messag}\n` +
'— Sent from dsfghgjgjuyfth-57301d31.com',
});
const id = data?.id;
if (error || !id) {
console.error('Resend failed', { error, data });
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Email could not be sent.',
});
}
console.info('Mail sent', { id });
return { ok: true };
} catch (error) {
console.error('Action error', error);
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Email could not be sent.',
});
}
},
}),
newsletterSubscribe: defineAction({
accept: 'form',
input: z.object({
email: z.string().email().max(254),
'cf-turnstile-response': turnstileField,
}),
handler: async ({ email, 'cf-turnstile-response': token }, ctx) => {
const remoteip = getRemoteIp(ctx.request);
const ok = await verifyTurnstile(token, remoteip, 'newsletter');
if (!ok) {
throw new ActionError({ code: 'BAD_REQUEST', message: 'Turnstile verification failed.' });
}
if (!KIT_API_KEY || !KIT_FORM_ID) {
console.error('Kit env missing', {
hasKey: Boolean(KIT_API_KEY),
hasFormId: Boolean(KIT_FORM_ID),
});
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Subscription failed.',
});
}
const email_address = stripCtl(email);
const headers = {
'Content-Type': 'application/json',
'X-Kit-Api-Key': KIT_API_KEY,
};
try {
const upsertRes = await fetchWithTimeout('https://api.kit.com/v4/subscribers', {
method: 'POST',
headers,
body: JSON.stringify({ email_address }),
});
const upsertJson = await upsertRes.json().catch(() => ({}));
if (!upsertRes.ok) {
console.error('Kit upsert failed', { status: upsertRes.status, upsertJson });
throw new ActionError({ code: 'BAD_REQUEST', message: 'Subscription failed.' });
}
const attachRes = await fetchWithTimeout(
`https://api.kit.com/v4/forms/${KIT_FORM_ID}/subscribers`,
{
method: 'POST',
headers,
body: JSON.stringify({ email_address }),
},
);
const attachJson = await attachRes.json().catch(() => ({}));
if (!attachRes.ok) {
console.error('Kit form attach failed', { status: attachRes.status, attachJson });
throw new ActionError({ code: 'BAD_REQUEST', message: 'Subscription failed.' });
}
return { ok: true };
} catch (error) {
console.error('Newsletter action error', error);
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Subscription failed.',
});
}
},
}),
};

Technical Details

Search: Powered by Pagefind for fast, static search. Image Zoom: Integrated with medium-zoom for a clean light-box experience. Icons: Handled via astro-icon. Code Blocks: Rendered using astro-expressive-code.

Want more posts like this?

Join the newsletter to get future essays and project notes without relying on social feeds.