Building a Modern Full-Stack Application with Astro, React, and Cloudflare
A comprehensive guide to building a scalable, performant web application using modern tools and best practices. Learn how to integrate Astro with React, implement database operations with Drizzle ORM, and deploy to Cloudflare Workers.
Sarah Johnson
Author
11 min read
Building a Modern Full-Stack Application
In this comprehensive guide, we’ll explore how to build a modern, scalable web application using some of the most exciting technologies in the web development ecosystem today. We’ll combine the power of Astro’s island architecture with React’s interactivity, Drizzle ORM’s type safety, and Cloudflare’s edge computing.
Introduction
Modern web development has evolved significantly, and the tools we use today enable us to build applications that are faster, more scalable, and more maintainable than ever before. In this tutorial, we’ll build a full-stack blog application that showcases these capabilities.
Why This Tech Stack?
Let’s examine why this particular combination of technologies makes sense:
Architecture Overview
Our application follows a modern JAMstack architecture with some server-side capabilities. Here’s how the components interact:
Key Benefits
- Performance: Astro’s partial hydration means only interactive components load JavaScript
- Scalability: Cloudflare Workers provide global edge computing
- Type Safety: End-to-end TypeScript with Drizzle ORM
- Developer Experience: Hot reload, excellent tooling, and modern APIs
Setting Up the Development Environment
Let’s start by setting up our development environment. We’ll need Node.js, npm, and a few other tools.
Prerequisites
# Check Node.js version (we need 18+)
node --version
# Check npm version
npm --version
# Install pnpm (optional but recommended)
npm install -g pnpm
Project Initialization
// package.json dependencies
{
"dependencies": {
"@astrojs/cloudflare": "^11.1.0",
"@astrojs/react": "^3.6.2",
"@astrojs/tailwind": "^5.1.1",
"astro": "^4.15.12",
"drizzle-orm": "^0.33.0",
"react": "^18.3.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/react": "^18.3.12",
"drizzle-kit": "^0.24.2",
"typescript": "^5.6.3",
"wrangler": "^3.84.1"
}
}
Environment Configuration
# .env.example
DATABASE_URL="file:./local.db"
CLOUDFLARE_ACCOUNT_ID="your-account-id"
CLOUDFLARE_API_TOKEN="your-api-token"
# Development
NODE_ENV="development"
PUBLIC_SITE_URL="http://localhost:4321"
# Production
# NODE_ENV="production"
# PUBLIC_SITE_URL="https://yourdomain.com"
Database Design with Drizzle ORM
Drizzle ORM provides excellent TypeScript integration and a familiar SQL-like syntax. Let’s design our database schema:
Schema Definition
// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
content: text('content').notNull(),
excerpt: text('excerpt'),
publishedAt: integer('published_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
authorId: integer('author_id').references(() => authors.id),
featured: integer('featured', { mode: 'boolean' }).default(false),
status: text('status', { enum: ['draft', 'published', 'archived'] }).default('draft')
})
export const authors = sqliteTable('authors', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
email: text('email').notNull().unique(),
bio: text('bio'),
avatar: text('avatar'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
})
export const tags = sqliteTable('tags', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull().unique(),
slug: text('slug').notNull().unique(),
description: text('description')
})
// Zod schemas for validation
export const insertPostSchema = createInsertSchema(posts)
export const selectPostSchema = createSelectSchema(posts)
export const insertAuthorSchema = createInsertSchema(authors)
export const selectAuthorSchema = createSelectSchema(authors)
export type Post = typeof posts.$inferSelect
export type NewPost = typeof posts.$inferInsert
export type Author = typeof authors.$inferSelect
export type NewAuthor = typeof authors.$inferInsert
Database Configuration
// src/db/index.ts
import { drizzle } from 'drizzle-orm/d1'
import type { DrizzleD1Database } from 'drizzle-orm/d1'
import * as schema from './schema'
export function createDB(d1: D1Database): DrizzleD1Database<typeof schema> {
return drizzle(d1, { schema })
}
export type DB = ReturnType<typeof createDB>
export { schema }
Migration Setup
// drizzle.config.ts
import type { Config } from 'drizzle-kit'
export default {
schema: './src/db/schema.ts',
out: './drizzle',
driver: 'd1',
dbCredentials: {
wranglerConfigPath: './wrangler.toml',
dbName: 'blog-db'
}
} satisfies Config
Building the Frontend with Astro and React
Now let’s create our frontend components. We’ll use Astro for static content and React for interactive elements.
Astro Layout Component
---
// src/layouts/BaseLayout.astro
export interface Props {
title: string
description?: string
}
const { title, description } = Astro.props
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
</head>
<body>
<main>
<slot />
</main>
</body>
</html>
<style is:global>
html {
font-family: system-ui, sans-serif;
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
}
</style>
Interactive React Component
// src/components/react/CommentForm.tsx
import React, { useState } from 'react'
import type { FormEvent } from 'react'
interface CommentFormProps {
postId: string
onSubmit?: (comment: { author: string; content: string }) => void
}
export default function CommentForm({ postId, onSubmit }: CommentFormProps) {
const [author, setAuthor] = useState('')
const [content, setContent] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
try {
const response = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ author, content }),
})
if (response.ok) {
const newComment = await response.json()
onSubmit?.(newComment)
setAuthor('')
setContent('')
}
} catch (error) {
console.error('Failed to submit comment:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="author" className="block text-sm font-medium">
Name
</label>
<input
type="text"
id="author"
value={author}
onChange={(e) => setAuthor(e.target.value)}
required
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium">
Comment
</label>
<textarea
id="content"
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={4}
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Submitting...' : 'Submit Comment'}
</button>
</form>
)
}
API Routes and Server-Side Logic
Astro’s API routes provide a clean way to handle server-side logic:
RESTful API Endpoints
// src/pages/api/posts/[id].ts
import type { APIRoute } from 'astro'
import { createDB } from '../../../db'
import { posts } from '../../../db/schema'
import { eq } from 'drizzle-orm'
export const GET: APIRoute = async ({ params, locals }) => {
const { id } = params
if (!id) {
return new Response(JSON.stringify({ error: 'Post ID required' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
})
}
try {
const db = createDB(locals.runtime.env.DB)
const post = await db
.select()
.from(posts)
.where(eq(posts.id, parseInt(id)))
.get()
if (!post) {
return new Response(JSON.stringify({ error: 'Post not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' }
})
}
return new Response(JSON.stringify(post), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
}
export const PUT: APIRoute = async ({ params, request, locals }) => {
const { id } = params
try {
const body = await request.json()
const db = createDB(locals.runtime.env.DB)
const updatedPost = await db
.update(posts)
.set({
...body,
updatedAt: new Date()
})
.where(eq(posts.id, parseInt(id!)))
.returning()
.get()
return new Response(JSON.stringify(updatedPost), {
headers: { 'Content-Type': 'application/json' }
})
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to update post' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
})
}
}
Data Fetching Utilities
// src/lib/api.ts
import type { Post, Author } from '../db/schema'
export class ApiClient {
private baseUrl: string
constructor(baseUrl = '') {
this.baseUrl = baseUrl
}
async getPosts(): Promise<Post[]> {
const response = await fetch(`${this.baseUrl}/api/posts`)
if (!response.ok) {
throw new Error('Failed to fetch posts')
}
return response.json()
}
async getPost(id: string): Promise<Post> {
const response = await fetch(`${this.baseUrl}/api/posts/${id}`)
if (!response.ok) {
throw new Error('Failed to fetch post')
}
return response.json()
}
async createPost(post: Omit<Post, 'id' | 'createdAt' | 'updatedAt'>): Promise<Post> {
const response = await fetch(`${this.baseUrl}/api/posts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(post),
})
if (!response.ok) {
throw new Error('Failed to create post')
}
return response.json()
}
}
export const api = new ApiClient()
Deployment to Cloudflare Workers
Deploying to Cloudflare Workers gives us global edge computing capabilities:
Wrangler Configuration
# wrangler.toml
name = "astro-blog"
main = "dist/worker.js"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
[env.production]
name = "astro-blog-production"
[[env.production.d1_databases]]
binding = "DB"
database_name = "blog-db"
database_id = "your-database-id"
[env.development]
name = "astro-blog-dev"
[[env.development.d1_databases]]
binding = "DB"
database_name = "blog-db-dev"
database_id = "your-dev-database-id"
Astro Configuration for Cloudflare
// astro.config.mjs
import { defineConfig } from 'astro/config'
import cloudflare from '@astrojs/cloudflare'
import react from '@astrojs/react'
import tailwind from '@astrojs/tailwind'
export default defineConfig({
output: 'server',
adapter: cloudflare({
imageService: 'cloudflare',
platformProxy: {
enabled: true
}
}),
integrations: [
react(),
tailwind()
],
vite: {
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV)
}
}
})
Performance Optimization
Let’s implement several performance optimizations:
Image Optimization
---
// src/components/OptimizedImage.astro
export interface Props {
src: string
alt: string
width?: number
height?: number
loading?: 'lazy' | 'eager'
}
const { src, alt, width = 800, height = 600, loading = 'lazy' } = Astro.props
---
<picture>
<source
srcset={`${src}?format=avif&width=${width}&height=${height}`}
type="image/avif"
/>
<source
srcset={`${src}?format=webp&width=${width}&height=${height}`}
type="image/webp"
/>
<img
src={`${src}?width=${width}&height=${height}`}
alt={alt}
width={width}
height={height}
loading={loading}
decoding="async"
/>
</picture>
Caching Strategy
// src/middleware/cache.ts
export function cacheHeaders(maxAge: number = 3600) {
return {
'Cache-Control': `public, max-age=${maxAge}`,
'CDN-Cache-Control': `public, max-age=${maxAge * 24}`,
'Vary': 'Accept-Encoding'
}
}
// Usage in API routes
export const GET: APIRoute = async () => {
const posts = await getPosts()
return new Response(JSON.stringify(posts), {
headers: {
'Content-Type': 'application/json',
...cacheHeaders(1800) // 30 minutes
}
})
}
Testing and Quality Assurance
Unit Testing with Vitest
// src/lib/__tests__/api.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ApiClient } from '../api'
// Mock fetch
global.fetch = vi.fn()
describe('ApiClient', () => {
let client: ApiClient
beforeEach(() => {
client = new ApiClient('https://test.com')
vi.clearAllMocks()
})
it('should fetch posts successfully', async () => {
const mockPosts = [
{ id: 1, title: 'Test Post', content: 'Test content' }
]
;(fetch as any).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockPosts)
})
const posts = await client.getPosts()
expect(fetch).toHaveBeenCalledWith('https://test.com/api/posts')
expect(posts).toEqual(mockPosts)
})
it('should handle fetch errors', async () => {
;(fetch as any).mockResolvedValueOnce({
ok: false,
status: 500
})
await expect(client.getPosts()).rejects.toThrow('Failed to fetch posts')
})
})
Integration Testing
// src/__tests__/integration/posts.test.ts
import { describe, it, expect } from 'vitest'
import { createDB } from '../../db'
import { posts } from '../../db/schema'
describe('Posts API Integration', () => {
it('should create and retrieve a post', async () => {
// This would use a test database
const db = createDB(testD1Database)
const newPost = {
title: 'Integration Test Post',
slug: 'integration-test-post',
content: 'This is a test post content',
createdAt: new Date(),
updatedAt: new Date()
}
const createdPost = await db.insert(posts).values(newPost).returning().get()
expect(createdPost.title).toBe(newPost.title)
expect(createdPost.id).toBeDefined()
})
})
Advanced Features
Real-time Updates with Server-Sent Events
// src/pages/api/posts/[id]/updates.ts
export const GET: APIRoute = async ({ params }) => {
const { id } = params
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
// Send initial data
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
)
// Simulate updates (in real app, this would listen to database changes)
const interval = setInterval(() => {
const update = {
type: 'comment_added',
postId: id,
timestamp: new Date().toISOString()
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(update)}\n\n`)
)
}, 5000)
// Cleanup on close
setTimeout(() => {
clearInterval(interval)
controller.close()
}, 60000)
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
})
}
Search Implementation
// src/lib/search.ts
import { posts, authors } from '../db/schema'
import { like, or, eq } from 'drizzle-orm'
import type { DB } from '../db'
export class SearchService {
constructor(private db: DB) {}
async searchPosts(query: string, limit = 10) {
const searchTerm = `%${query}%`
return await this.db
.select({
id: posts.id,
title: posts.title,
excerpt: posts.excerpt,
slug: posts.slug,
publishedAt: posts.publishedAt,
authorName: authors.name
})
.from(posts)
.leftJoin(authors, eq(posts.authorId, authors.id))
.where(
or(
like(posts.title, searchTerm),
like(posts.content, searchTerm),
like(posts.excerpt, searchTerm)
)
)
.limit(limit)
}
async getPopularTags() {
// Implementation for getting popular tags
// This would involve a tags table and post_tags junction table
}
}
Conclusion
We’ve built a comprehensive, modern web application using some of the best tools available today. Our stack provides:
- Excellent Performance: Thanks to Astro’s partial hydration and Cloudflare’s edge network
- Type Safety: End-to-end TypeScript with Drizzle ORM
- Scalability: Cloudflare Workers can handle massive traffic spikes
- Developer Experience: Hot reload, excellent tooling, and modern APIs
- Maintainability: Clean architecture and comprehensive testing
Key Takeaways
- Choose the right tool for each job: Static content in Astro, interactive components in React
- Embrace edge computing: Cloudflare Workers provide global performance
- Type safety is crucial: Drizzle ORM and TypeScript catch errors early
- Performance is a feature: Optimize from the beginning, not as an afterthought
- Test everything: Unit tests, integration tests, and performance monitoring
Next Steps
- Implement advanced caching strategies
- Add real-time features with WebSockets
- Integrate analytics and monitoring
- Set up CI/CD pipelines
- Implement progressive web app features
This architecture serves as a solid foundation for building modern web applications that can scale to millions of users while maintaining excellent performance and developer experience.
Have questions about this implementation? Feel free to reach out or check out the full source code on GitHub.