G'day mates, I'm Loftwah, and here's my guide to setting up an Astro 5.x project using TypeScript, Tailwind 4, Cloudflare Pages with Functions, and Cloudflare D1 integration. I've also added a test CRUD (Create, Read, Update, Delete) example to show you how to handle data with D1.
If you want to use my template as a starting point:
git clone [email protected]:loftwah/cloudflare-template.git
cd cloudflare-template
Or start from scratch:
mkdir my-astro-site
cd my-astro-site
npm create astro@latest
- Choose a project name
- Select a template (like "Empty")
- Configure TypeScript (Yes, strict)
- Initialize Git repository (Yes)
- Install dependencies (Yes)
npm list astro
npm run dev
Visit http://localhost:4321 to confirm your project is running.
npx astro add tailwind
This command installs required dependencies and creates the necessary configuration files.
tailwind.config.ts:
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./src/**/*.{astro,html,js,jsx,ts,tsx,md,mdx,svelte,vue}"],
theme: {
extend: {},
},
plugins: [],
};
export default config;
src/styles/global.css:
@import "tailwindcss";
Make sure your Layout component imports the global CSS:
---
// src/layouts/Layout.astro
const { title } = Astro.props;
---
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
<style is:global>
@import '../styles/global.css';
</style>
npx astro add cloudflare
Make sure your configuration includes server output mode and the Cloudflare adapter:
import { defineConfig } from "astro/config";
import tailwindPlugin from "@tailwindcss/vite";
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
vite: {
plugins: [tailwindPlugin()],
},
output: "server", // Enable SSR
adapter: cloudflare()
});
npm install -D wrangler
Add a preview script for testing with Wrangler:
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "wrangler pages dev ./dist",
"astro": "astro"
}
- Create a new D1 database in the Cloudflare Dashboard (Workers & Pages > D1)
- Note the database name and ID
Create a wrangler.toml
file in your project root:
name = "cloudflare-template"
compatibility_date = "2025-03-19"
pages_build_output_dir = "./dist"
[[d1_databases]]
binding = "DB"
database_name = "my-d1-db"
database_id = "your-database-id"
Replace your-database-id
with your actual D1 database ID.
Create API endpoints in src/pages/api/
:
src/pages/api/index.ts (Simple database health check):
import type { APIContext } from 'astro';
interface CloudflareLocals {
runtime: {
env: {
DB: any; // D1 database
};
};
}
export async function GET({ locals }: APIContext & { locals: CloudflareLocals }) {
try {
const stmt = await locals.runtime.env.DB.prepare("SELECT 1 as test").run();
return new Response(JSON.stringify({
message: "D1 database connection successful",
data: stmt.results,
time: new Date().toISOString()
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error('Database error:', error);
return new Response(JSON.stringify({
error: 'Database error',
message: error instanceof Error ? error.message : String(error)
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
npm run build
npm run preview -- --d1=DB
Visit http://localhost:8788/api to test D1 connection.
Create a table in your D1 database:
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
description TEXT
);
Create file src/pages/api/items.ts
for CRUD operations:
import type { APIContext } from 'astro';
import { verifyAuth, unauthorizedResponse } from '../../utils/auth';
interface CloudflareLocals {
runtime: {
env: {
DB: any; // D1 database
};
};
}
interface Item {
id?: number;
name: string;
description: string;
}
// GET all items
export async function GET({ locals, request }: APIContext & { locals: CloudflareLocals }) {
// Check authentication
if (!verifyAuth(request)) {
return unauthorizedResponse();
}
try {
const { results } = await locals.runtime.env.DB.prepare("SELECT * FROM items").all();
return new Response(JSON.stringify({
success: true,
data: results
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error('Error fetching items:', error);
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : String(error)
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
// POST - create a new item
export async function POST({ request, locals }: APIContext & { locals: CloudflareLocals }) {
// Check authentication
if (!verifyAuth(request)) {
return unauthorizedResponse();
}
try {
const item: Item = await request.json();
if (!item.name) {
return new Response(JSON.stringify({
success: false,
error: "Name is required"
}), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const { success, meta } = await locals.runtime.env.DB.prepare(
"INSERT INTO items (name, description) VALUES (?, ?)"
)
.bind(item.name, item.description || "")
.run();
if (!success) {
throw new Error("Failed to insert item");
}
return new Response(JSON.stringify({
success: true,
id: meta.last_row_id
}), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error('Error creating item:', error);
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : String(error)
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
// PUT - update an existing item
export async function PUT({ request, locals }: APIContext & { locals: CloudflareLocals }) {
// Check authentication
if (!verifyAuth(request)) {
return unauthorizedResponse();
}
try {
const item: Item = await request.json();
if (!item.id) {
return new Response(JSON.stringify({
success: false,
error: "Item ID is required"
}), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const { success } = await locals.runtime.env.DB.prepare(
"UPDATE items SET name = ?, description = ? WHERE id = ?"
)
.bind(item.name, item.description || "", item.id)
.run();
if (!success) {
throw new Error("Failed to update item");
}
return new Response(JSON.stringify({
success: true,
message: "Item updated successfully"
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error('Error updating item:', error);
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : String(error)
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
// DELETE - delete an item
export async function DELETE({ request, locals }: APIContext & { locals: CloudflareLocals }) {
// Check authentication
if (!verifyAuth(request)) {
return unauthorizedResponse();
}
try {
const url = new URL(request.url);
const id = url.searchParams.get('id');
if (!id) {
return new Response(JSON.stringify({
success: false,
error: "Item ID is required"
}), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const { success } = await locals.runtime.env.DB.prepare(
"DELETE FROM items WHERE id = ?"
)
.bind(parseInt(id))
.run();
if (!success) {
throw new Error("Failed to delete item");
}
return new Response(JSON.stringify({
success: true,
message: "Item deleted successfully"
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error('Error deleting item:', error);
return new Response(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : String(error)
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
Create a simple authentication helper at src/utils/auth.ts
:
// Basic authentication utility
const BASIC_PASSWORD = "password123";
/**
* Verify the authentication from request headers or query parameters
*/
export function verifyAuth(request: Request): boolean {
// Check for auth in headers (for API calls)
const authHeader = request.headers.get("Authorization");
if (authHeader) {
// Simple password-based auth
const password = authHeader.replace("Bearer ", "");
return password === BASIC_PASSWORD;
}
// Check for auth in URL (for browser access)
const url = new URL(request.url);
const password = url.searchParams.get("auth");
if (password === BASIC_PASSWORD) {
return true;
}
return false;
}
/**
* Create an unauthorized response
*/
export function unauthorizedResponse(): Response {
return new Response(JSON.stringify({
success: false,
error: "Unauthorized - Authentication required"
}), {
status: 401,
headers: {
"Content-Type": "application/json",
"WWW-Authenticate": "Bearer"
}
});
}
/**
* Create a redirect to login response
*/
export function redirectToLogin(): Response {
return new Response(null, {
status: 302,
headers: {
"Location": "/login"
}
});
}
Add src/pages/api/test-items.ts
for testing CRUD operations:
import type { APIContext } from 'astro';
import { verifyAuth, unauthorizedResponse } from '../../utils/auth';
interface CloudflareLocals {
runtime: {
env: {
DB: any; // D1 database
};
};
}
export async function GET({ locals, request }: APIContext & { locals: CloudflareLocals }) {
// Check authentication
if (!verifyAuth(request)) {
return unauthorizedResponse();
}
const testResults = {
setup: { success: false, message: '' },
createTest: { success: false, message: '', id: null },
readTest: { success: false, message: '', data: null },
updateTest: { success: false, message: '' },
deleteTest: { success: false, message: '' },
cleanup: { success: false, message: '' }
};
try {
// 1. Setup - ensure the table exists
try {
await locals.runtime.env.DB.prepare(`
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
description TEXT
)
`).run();
testResults.setup = { success: true, message: 'Table setup successful' };
// Run through all CRUD operations to test
// See full implementation in the source code
} catch (error) {
testResults.setup = {
success: false,
message: `Table setup failed: ${error instanceof Error ? error.message : String(error)}`
};
}
return new Response(JSON.stringify({
message: "CRUD Test completed",
results: testResults,
time: new Date().toISOString()
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error('Test error:', error);
return new Response(JSON.stringify({
error: 'Test failed',
message: error instanceof Error ? error.message : String(error),
results: testResults
}), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
Create src/pages/login.astro
:
---
import Layout from '../layouts/Layout.astro';
const errorMessage = Astro.url.searchParams.get('error') ? 'Invalid password. Please try again.' : null;
---
<Layout title="Login - Cloudflare Items Manager">
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Sign in to access the Items Manager
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Please enter the password to continue
</p>
</div>
{errorMessage && (
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
{errorMessage}
</h3>
</div>
</div>
</div>
)}
<form class="mt-8 space-y-6" id="loginForm">
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="password" class="sr-only">Password</label>
<input id="password" name="password" type="password" required class="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm" placeholder="Password">
</div>
</div>
<div>
<button type="submit" class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Sign in
</button>
</div>
</form>
</div>
</div>
<script>
const loginForm = document.getElementById('loginForm');
if (loginForm) {
loginForm.addEventListener('submit', function(e) {
e.preventDefault();
const passwordInput = document.getElementById('password');
if (!passwordInput || !(passwordInput instanceof HTMLInputElement)) return;
const password = passwordInput.value;
// Redirect to the items page with the password as a query parameter
window.location.href = `/items?auth=${encodeURIComponent(password)}`;
});
}
</script>
</Layout>
Create src/pages/items.astro
:
---
import Layout from '../layouts/Layout.astro';
import { verifyAuth, redirectToLogin } from '../utils/auth';
// Check for authentication
const isAuthenticated = verifyAuth(Astro.request);
if (!isAuthenticated) {
return redirectToLogin();
}
// Get all items from the database
let items = [];
try {
// Access the Cloudflare D1 database
// @ts-ignore - Cloudflare runtime is added at runtime
const db = Astro.locals?.runtime?.env?.DB;
if (db) {
// First ensure the table exists
await db.prepare(`
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
description TEXT
)
`).run();
// Then fetch the items
const response = await db.prepare("SELECT * FROM items").all();
items = response.results || [];
} else {
console.error('Database not available in Astro.locals.runtime.env');
}
} catch (error) {
console.error('Error fetching items:', error);
}
// Get the auth parameter for passing to API calls
const authParam = new URL(Astro.request.url).searchParams.get('auth') || '';
---
<Layout title="Items Manager">
<div class="max-w-6xl mx-auto p-4 sm:p-6 lg:p-8">
<header class="bg-white shadow rounded-lg mb-6 p-4">
<h1 class="text-3xl font-bold text-gray-800">Items Manager</h1>
<p class="text-gray-600">Manage your items database</p>
<div class="flex justify-between mt-2">
<a href={`/?auth=${authParam}`} class="inline-block text-indigo-600 hover:text-indigo-800">← Back to Home</a>
<div>
<button id="runTestBtn" class="inline-block text-green-600 hover:text-green-800 mr-4">Run DB Test</button>
<a href="/login" class="inline-block text-red-600 hover:text-red-800">Logout</a>
</div>
</div>
</header>
<!-- Add Item Form -->
<div class="bg-white shadow rounded-lg mb-6 p-4">
<h2 class="text-xl font-semibold text-gray-800 mb-4">Add New Item</h2>
<form id="itemForm" class="space-y-4">
<!-- Form fields here -->
</form>
</div>
<!-- Items List -->
<div class="bg-white shadow rounded-lg">
<h2 class="text-xl font-semibold text-gray-800 p-4 border-b">Items List</h2>
<div id="itemsList" class="divide-y divide-gray-200">
{items.map((item: any) => (
<div class="item-row p-4 flex justify-between items-start hover:bg-gray-50" data-id={item.id}>
<div>
<h3 class="text-lg font-medium text-gray-900">{item.name}</h3>
<p class="mt-1 text-sm text-gray-600">{item.description}</p>
</div>
<div class="flex space-x-2">
<button class="edit-btn px-3 py-1 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 text-sm">
Edit
</button>
<button class="delete-btn px-3 py-1 bg-red-100 text-red-700 rounded-md hover:bg-red-200 text-sm">
Delete
</button>
</div>
</div>
))}
{items.length === 0 && (
<div class="p-4 text-center text-gray-500">
No items found. Add your first item above.
</div>
)}
</div>
</div>
<!-- Client-side JavaScript for CRUD operations -->
<script define:vars={{ authParam }}>
// JavaScript code for handling forms, modals, and CRUD operations
// See full implementation in the source code
</script>
</div>
</Layout>
git add .
git commit -m "Initial commit"
git push origin main
- Go to Cloudflare Dashboard > Workers & Pages > Create application
- Connect your GitHub repository
- Configure the build settings:
- Build command:
npm run build
- Build output directory:
dist
- Build command:
- Add the D1 binding:
- Variable name:
DB
- Select your D1 database
- Variable name:
After deployment, visit your Cloudflare Pages URL to test the application:
/api
: Test D1 connection/login
: Enter password "password123" to access the app/items?auth=password123
: Items management interface
You can test CRUD operations using the UI or via API endpoints:
- GET
/api/items?auth=password123
: List all items - POST
/api/items
with Authorization header: Create an item - PUT
/api/items
with Authorization header: Update an item - DELETE
/api/items?id=1&auth=password123
: Delete an item
If you encounter D1 connection issues:
- Verify your wrangler.toml configuration is correct
- Ensure you're passing the
--d1=DB
flag when running the preview
npm run preview -- --d1=DB
- Check permissions in the Cloudflare dashboard
Note that API routes in Astro are built in the src/pages/api
directory, not in a separate functions
directory. Each TypeScript file automatically becomes an API endpoint.
Enjoy your Astro + Cloudflare + D1 project! If you need any help, feel free to reach out.