Skip to content

Instantly share code, notes, and snippets.

@loftwah
Last active March 20, 2025 10:04
Show Gist options
  • Save loftwah/14d8d0acb11b6029da2aec252e8b8b32 to your computer and use it in GitHub Desktop.
Save loftwah/14d8d0acb11b6029da2aec252e8b8b32 to your computer and use it in GitHub Desktop.
Astro Setup Guide

Loftwah's Astro Guide

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.


Step 1: Set Up Astro 5.x Project

1. Clone the Template Repository (Optional)

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

2. Create the Astro Project

npm create astro@latest
  • Choose a project name
  • Select a template (like "Empty")
  • Configure TypeScript (Yes, strict)
  • Initialize Git repository (Yes)
  • Install dependencies (Yes)

3. Verify Your Installation

npm list astro

4. Test Locally

npm run dev

Visit http://localhost:4321 to confirm your project is running.


Step 2: Integrate Tailwind

1. Add Tailwind to Your Project

npx astro add tailwind

This command installs required dependencies and creates the necessary configuration files.

2. Verify the 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";

3. Update Your Layout Component

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>

Step 3: Configure Cloudflare Pages with SSR

1. Add the Cloudflare Adapter

npx astro add cloudflare

2. Update astro.config.mjs

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()
});

3. Install Wrangler for Local Development

npm install -D wrangler

4. Update package.json Scripts

Add a preview script for testing with Wrangler:

"scripts": {
  "dev": "astro dev",
  "build": "astro build",
  "preview": "wrangler pages dev ./dist",
  "astro": "astro"
}

Step 4: Add Cloudflare D1 Integration

1. Create a D1 Database

  • Create a new D1 database in the Cloudflare Dashboard (Workers & Pages > D1)
  • Note the database name and ID

2. Configure Wrangler.toml

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.

3. Create API Routes for D1 Access

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" },
    });
  }
}

4. Build and Test Locally with D1

npm run build
npm run preview -- --d1=DB

Visit http://localhost:8788/api to test D1 connection.


Step 5: Implement CRUD Operations

1. Create Database Table

Create a table in your D1 database:

CREATE TABLE IF NOT EXISTS items (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT,
  description TEXT
);

2. Create API Endpoints for Items

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" },
    });
  }
}

3. Create Authentication Utilities

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"
    }
  });
}

4. Create Test Endpoints

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" },
    });
  }
}

Step 6: Create UI Pages

1. Login Page

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>

2. Items Management Page

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>

Step 7: Deploy to Cloudflare Pages

1. Push to GitHub

git add .
git commit -m "Initial commit"
git push origin main

2. Connect Your Repository to Cloudflare Pages

  • 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
  • Add the D1 binding:
    • Variable name: DB
    • Select your D1 database

3. Deploy and Test

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

4. Test CRUD Operations

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

Troubleshooting

D1 Connection Issues

If you encounter D1 connection issues:

  1. Verify your wrangler.toml configuration is correct
  2. Ensure you're passing the --d1=DB flag when running the preview
npm run preview -- --d1=DB
  1. Check permissions in the Cloudflare dashboard

API Route Structure

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment