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

Modern web development setup with multiple monitors showing code

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:

User Request

Cloudflare Edge

Astro SSR

React Islands

API Routes

Drizzle ORM

Cloudflare D1

Architecture Overview

Our application follows a modern JAMstack architecture with some server-side capabilities. Here’s how the components interact:

Infrastructure

Data Layer

API Layer

Frontend Layer

Astro Pages

React Components

TypeScript

Astro API Routes

Server Functions

Drizzle ORM

Cloudflare D1

Schema Validation

Cloudflare Workers

Edge Locations

CDN

Key Benefits

  1. Performance: Astro’s partial hydration means only interactive components load JavaScript
  2. Scalability: Cloudflare Workers provide global edge computing
  3. Type Safety: End-to-end TypeScript with Drizzle ORM
  4. 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:

Measurements

Performance Strategy

Static Site Generation

Partial Hydration

Edge Caching

Image Optimization

Code Splitting

Core Web Vitals

Time to First Byte

First Contentful Paint

Largest Contentful Paint

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

  1. Choose the right tool for each job: Static content in Astro, interactive components in React
  2. Embrace edge computing: Cloudflare Workers provide global performance
  3. Type safety is crucial: Drizzle ORM and TypeScript catch errors early
  4. Performance is a feature: Optimize from the beginning, not as an afterthought
  5. 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.