Edge-First Serverless Stack: Astro 5 + Cloudflare R2 + Drizzle ORM + Qwik
Build a modern serverless application with Astro 5 Islands architecture, Cloudflare R2 storage, Drizzle ORM, Qwik resumability, and edge caching.
- Step 1
Project Initialization with Astro 5
Start by creating a new Astro project with TypeScript. Astro 5 introduces improved Islands architecture for partial hydration, allowing you to deliver mostly-static HTML with selective interactivity.
npm create astro@latest edge-serverless-stack # Select options: # - TypeScript: Yes # - Install dependencies: Yes # - Initialize git: Yes # - ASTRO_DB: No (using Drizzle separately) - Step 2
Install Core Dependencies
Install the core packages for the stack: Qwik for resumable components, Drizzle ORM for type-safe database operations, and Cloudflare Workers SDK.
npm add @builder.io/qwik @builder.io/qwik-city npm add drizzle-orm drizzle-kit @electric-sql/pglite npm add wrangler @cloudflare/workers-types npm add -D typescript @types/node - Step 3
Configure Astro 5 with Qwik Integration
Set up Astro to work with Qwik by installing the Qwik adapter. Configure the project to use Cloudflare Workers as the deployment target.
// astro.config.mjs import defineConfig from 'astro/config'; import qwik from '@astrojs/qwik'; import cloudflare from '@astrojs/cloudflare'; export default defineConfig({ integrations: [ qwik({ // Qwik configuration devRequestHandler: true, }), ], adapter: cloudflare({ routes: [ { pattern: '/api/*', environment: 'api' }, { pattern: '/assets/*', environment: 'assets' }, ], }), build: { client: 'dist/client', server: 'dist/server', }, }); - Step 4
Set Up Drizzle ORM Schema and Migrations
Define your database schema using Drizzle ORM. Drizzle provides type-safe SQL queries and works well with edge databases like Cloudflare D1 or external PostgreSQL instances.
// src/db/schema.ts import { pgTable, varchar, timestamp, uuid } from 'drizzle-orm/pg-core'; export const posts = pgTable('posts', { id: uuid('id').primaryKey().defaultRandom(), title: varchar('title', { length: 255 }).notNull(), slug: varchar('slug', { length: 255 }).notNull().unique(), content: varchar('content', { length: 10000 }).notNull(), publishedAt: timestamp('published_at').notNull().defaultNow(), authorId: uuid('author_id').notNull().references(() => users.id), }); export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), email: varchar('email', { length: 255 }).notNull().unique(), name: varchar('name', { length: 100 }).notNull(), createdAt: timestamp('created_at').notNull().defaultNow(), }); - Step 5
Configure Database Connection for Edge
Set up the Drizzle connection to work at the edge. Use connection pooling and async connections for optimal performance.
// src/db/drizzle.ts import { drizzle } from 'drizzle-orm/peggy'; import * as schema from './schema'; export function getDb() { const db = drizzle({ schema, logger: true, }); return db; } // For Cloudflare D1 or external Postgres // src/db/d1.ts import { drizzle } from 'drizzle-orm/d1'; import * as schema from './schema'; export function getD1(db: D1Database) { return drizzle(db, { schema }); } - Step 6
Configure Cloudflare R2 for Object Storage
Set up Cloudflare R2 for storing user uploads, images, and static assets. R2 provides S3-compatible storage with no egress fees.
// wrangler.toml name = "edge-serverless-stack" compatibility_date = "2024-01-01" [[r2_buckets]] binding = "STORAGE" bucket_name = "my-app-bucket" preview_bucket_name = "my-app-bucket-preview" [[d1_databases]] binding = "DB" database_name = "my-app-db" database_id = "your-database-id" local_binding = true - Step 7
Create R2 Service for File Operations
Build a service layer for R2 operations including file uploads, downloads, and signed URL generation for secure temporary access.
// src/services/r2.ts import { createClient } from '@upstash/ratelimit'; export class R2Service { private storage: R2Bucket; constructor(storage: R2Bucket) { this.storage = storage; } async uploadFile( key: string, body: BodyInit, metadata?: Record<string, string> ): Promise<R2Object> { return await this.storage.put(key, body, { httpMetadata: new ResponseMetadata({ contentType: this.detectContentType(key), }), customMetadata: metadata, }); } async generateSignedUrl(key: string, expiresInSeconds: number = 3600) { const object = await this.storage.get(key); if (!object) throw new Error('File not found'); return await object.getSignedUrl({ expiresIn: expiresInSeconds, parameters: { 'response-content-disposition': `attachment; filename="${key}"` } as any, }); } private detectContentType(key: string): string { const extMap: Record<string, string> = { '.jpg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf', }; const ext = key.slice(key.lastIndexOf('.')).toLowerCase(); return extMap[ext] || 'application/octet-stream'; } } - Step 8
Implement Astro Islands with Partial Hydration
Create interactive islands using Astro's partial hydration. Islands allow you to keep most of your HTML static while only hydrating specific interactive components on the client.
-- src/components/InteractivePost.astro <script> import { Component as QwikComponent } from '@builder.io/qwik'; import PostCard from './PostCard.astro'; </script> <!-- Non-interactive content renders as static HTML --> <article> <h1>{props.title}</h1> <p>{props.excerpt}</p> <!-- Island: Only this section gets hydrated --> <client:load> <PostCard title={props.title} content={props.content} /> </client:load> </article> <!-- Alternative using Qwik's resumability --> <!-- src/components/QwikPost.astro --> <script> import { component$ } from '@builder.io/qwik'; import { useStore } from '@builder.io/qwik'; const QwikPost = component$(() => { const state = useStore({ loading: true }); return ( <article> <h1>{props.title}</h1> {state.loading ? <LoadingSpinner /> : <PostContent />} </article> ); }); </script> - Step 9
Build Edge Functions for Serverless Logic
Create edge functions that run close to your users for low-latency API responses. Edge functions can handle authentication, data fetching, and real-time processing.
// src/pages/api/posts/[slug]/[slug].ts import type { APIRoute } from 'astro'; export async function GET( { params, request, context }: APIRoute ) { const { slug } = await params; // Cache control for edge caching const cacheControl = request.headers.get('x-cache-control'); // Fetch from database or cache const post = await getPostBySlug(slug); if (!post) { return new Response('Not found', { status: 404 }); } return new Response(JSON.stringify(post), { headers: { 'Content-Type': 'application/json', 'Cache-Control': cacheControl || 'public, max-age=300, stale-while-revalidate=60', 'Vary': 'Accept-Encoding', }, }); } - Step 10
Implement Edge Caching Strategy
Configure HTTP caching at the edge using Cache-Control headers and Cloudflare's caching mechanisms. Implement stale-while-revalidate for optimal performance.
// src/middleware/cache.ts import type { APIContext } from 'astro'; export async function cacheMiddleware( context: APIContext, next: () => Promise<Response> ): Promise<Response> { const response = await next(); // Cache static assets for extended period if (context.url.pathname.startsWith('/assets/')) { response.headers.set('Cache-Control', 'public, max-age=31536000, immutable'); } // Cache API responses with validation else if (context.url.pathname.startsWith('/api/')) { response.headers.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=300'); response.headers.set('Stale', 'while-revalidate'); } // Cache HTML pages with short TTL else { response.headers.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=3600'); } return response; } // Wrangler configuration for Cloudflare cache // wrangler.toml [site] bucket = "static-bucket" [build] command = "npm run build" upload = { format = "service-worker" } - Step 11
Incremental Static Regeneration (ISR)
Implement incremental static regeneration to update pages on-demand or based on schedules. This allows you to have mostly-static sites with fresh content.
// src/pages/blog/[slug].astro import type { CollectionEntry } from 'astro:content'; export async function getStaticPaths() { const posts = await astro.content.collection('posts').all(); return posts.map((post) => ({ params: { slug: post.slug }, props: { post }, })); } // ISR with revalidate option export async function GET({ params, redirect }) { const { slug } = await params; const post = await getContentBySlug(slug); if (!post) { redirect('/404'); } return astro.render({ title: post.title, post }); } // Manual revalidation via webhook // src/pages/api/revalidate/[slug].ts export async function POST( { params, request, context } ) { const webhookSecret = request.headers.get('x-revalidate-secret'); if (webhookSecret !== process.env.REVALIDATE_SECRET) { return new Response('Unauthorized', { status: 401 }); } await context.revalidate({ path: `/blog/${await params}.slug`, }); return new Response('OK'); } - Step 12
Qwik Resumability vs React Hydration
Understand the difference between Qwik's resumability and React's hydration. Qwik serializes state to attribute selectors and only loads code when needed, while React loads all components and hydrates them. Qwik's approach results in smaller bundle sizes and faster initial loads.
// Qwik: Resumability pattern // Code is lazy-loaded only when an event occurs import { component$, $, useStore } from '@builder.io/qwik'; export const Counter = component$(() => { const state = useStore({ count: 0 }); const increment$ = $() => { state.count++; }; return ( <div> <p>Count: {state.count}</p> <button onClick$={increment$}>Increment</button> </div> ); }); // Generated HTML contains q:slot attributes for serialization // <div q:slot="/Counter#0"><p q:slot="/Counter#0:1">Count: 0</p></div> // No JavaScript loaded until user interacts // React hydration: full component tree preloaded import React, { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); return ( <div> <p>Count: {count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); } // All code must be loaded and hydrated before interaction⚠ Heads up: Qwik has a steeper learning curve than React. The $() signal function and component$ are essential for proper resumability. - Step 13
Performance Monitoring and Lighthouse Scoring
Set up performance monitoring using tools like Lighthouse CI, Web Vitals, and Cloudflare Analytics. Track Core Web Vitals metrics to ensure optimal user experience.
{ "ci": { "assert": { "assertion": { "firstContentfulPaint": { "max": 1500 }, "largestContentfulPaint": { "max": 2500 }, "totalBlockingTime": { "max": 300 }, "cumulativeLayoutShift": { "max": 0.1 }, "speedIndex": { "max": 3000 } } }, "collect": { "url": "https://your-deployment.cloudflareworkers.com", "settings": { "preset": "mobile" }, "numberOfRuns": 3, "outputPath": "./lighthouse-report.json" } } } // package.json { "scripts": { "lhci": "lhci autorun", "measure": "lighthouse http://localhost:4321 --view" } } - Step 14
JAMstack Project Setup Summary
Complete your JAMstack setup by configuring environment variables, CI/CD pipelines, and monitoring dashboards for production deployment.
# Environment variables needed # .env.local NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co SUPABASE_ANON_KEY=your-anon-key CLOUDFLARE_ACCOUNT_ID=your-account-id CLOUDFLARE_API_TOKEN=your-api-token NODE_ENV=production # Deploy to Cloudflare Workers npx wrangler deploy # Set up CDN caching rules curl --request POST \ --url https://api.cloudflare.com/client/v4/zones/{zone_id}/cache/config \ --header 'Authorization: Bearer {api_token}' \ --data '{"cache_rules": [{"status": 200, "ttl": 86400}]}' - Step 15
Static Asset Optimization
Optimize static assets for the edge by using image sprites, code splitting, and CDN delivery. Astro 5 provides built-in optimizations for images, fonts, and scripts.
// Configure Astro for image optimization // astro.config.mjs export default defineConfig({ images: { domains: ['cdn.example.com'], service: 'sharp' }, compressHTML: true, compressScripts: true, compressStyles: true, serverTiming: { enabled: true, }, }); // Use Astro's image component for automatic optimization // src/components/OptimizedImage.astro <script> import Image from 'astro:asset'; interface Props { src: string; alt: string; width?: number; height?: number; } </script> <figure> <Image src={image} alt={alt} width={width} height={height} quality={75} fetchpriority="high" /> <figcaption>{alt}</figcaption> </figure>
Feature requests
Sign in to suggest features or vote on existing ones.
No feature requests yet.
Discussion
Sign in to join the discussion.
No comments yet.