DEV Community

Cover image for I Built a Modern Serverless JS Full-Stack Framework in One Day
Ahmed Rakan
Ahmed Rakan

Posted on • Edited on

I Built a Modern Serverless JS Full-Stack Framework in One Day

Yes, you read that right — I designed and implemented a modern, serverless JavaScript full-stack framework in one day.

This isn’t clickbait. I didn’t do it to flex or impress anyone — I simply needed it for a project I’m working on.

That project is an innovative, AI-first retrieval database, designed to run serverlessly and take advantage of Cloudflare’s edge network.

Why Cloudflare?

Because it’s free to start, cheaper at scale, and gives you global performance out of the box — perfect for modern edge-native apps.

The Stack I Chose

Here’s what powers the framework:

  • Backend: Hono – lightweight, fast, and extendable.
  • Frontend: React – my go-to for building UI/UX at speed.
  • Build System: Vite – blazing fast for both backend and frontend.
  • Runtime: Node.js locally, Cloudflare Workers in production.
  • Package Ecosystem: NPM – for maximum compatibility with JS libraries.
  • Deployment: Cloudflare Workers (backend) + Cloudflare Pages (frontend).

This stack stands shoulder-to-shoulder with popular modern stacks like Vercel’s Next.js or OpenNext — but with fewer limitations and zero vendor lock-in.

You get:

✅ Edge functions powered by Cloudflare Workers

✅ Highly performant, scalable codebase

✅ Great DX (developer experience)

And most importantly — you’re free to build and scale without being boxed in.


Step-by-Step: Building the Framework

We need eight things to make this work:

  1. Backend: Hono
  2. Frontend: React
  3. Bundler: Vite
  4. Backend–Frontend Link: Vite
  5. Runtime: Node locally, Workers in production
  6. Ecosystem: NPM
  7. Deployment: Cloudflare Workers + Pages
  8. Developer: (That’s me — the tech wizard Ahmed. 😄)

Important:

In order to make our stack happen and feels like a full-stack development framework, we will follow monorepo architecture using npm workspaces, please look here : https://daveiscoding.hashnode.dev/nodejs-typescript-monorepo-via-npm-workspaces


Folder Structure

We’ll follow a monorepo structure, with:

  • packages/ → contains client/ and server/
  • types/ → shared types outside of packages/
  • root configuration files (package.json, tsconfig.json, etc.)

Root Configuration

Here we define configurations that link backend, frontend, and shared packages.

package.json

{ "name": "my-hono-react-app", "private": true, "type": "module", "workspaces": [ "packages/*", "types" ], "scripts": { "dev": "concurrently \"npm run dev:client\" \"npm run dev:server\"", "dev:client": "npm run dev -w client", "dev:server": "npm run dev -w server", "watch": "concurrently \"npm run watch -w client\" \"npm run watch -w server\"", "build": "npm run build -w client && npm run build -w server", "deploy": "npm run deploy -w server" }, "devDependencies": { "@rollup/rollup-win32-x64-msvc": "^4.50.1", "concurrently": "^8.2.2", "wrangler": "^3.0.0" }, "dependencies": { "lightningcss": "^1.30.1" } } 
Enter fullscreen mode Exit fullscreen mode

💡 Note: If you’re on Windows, you’ll need @rollup/rollup-win32-x64-msvc.

tsconfig.json

{ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", }, "include": ["src/**/*", "*.ts"], } 
Enter fullscreen mode Exit fullscreen mode

wrangler.toml

name = "hono-react-app" main = "src/server/index.ts" compatibility_date = "2024-01-01" compatibility_flags = ["nodejs_compat"] [vars] NODE_ENV = "development" [env.production] [env.production.vars] NODE_ENV = "production" # For serving static assets in production [assets] directory = "./dist/client" binding = "ASSETS" [build] command = "npm run build" # For development [dev] port = 8787 
Enter fullscreen mode Exit fullscreen mode

(And yes, you still need your .gitignore and README.md — jk, let’s keep going 😄)


Shared Types

The types folder stores code shared across client and server. You can create many of these as you wish.

package.json

{ "name": "types", "private": true, "type": "module", "types": "./index.d.ts", "files": [ "**/*.d.ts", "**/*.ts" ], "scripts": { "build": "tsc --project tsconfig.json" } } 
Enter fullscreen mode Exit fullscreen mode

tsconfig.json

{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "declaration": true, "declarationMap": true, "outDir": "../dist-types", "rootDir": ".", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": [ "**/*.ts" ], "exclude": [ "node_modules", "dist-types" ] } 
Enter fullscreen mode Exit fullscreen mode

Packages → Server

package.json

{ "name": "server", "type": "module", "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy --minify", "build": "npm run build -w client && tsc --noEmit", "watch": "tsc --noEmit --watch --preserveWatchOutput", "build:server": "tsc && cp -r ../dist/client ./dist/", "cf-typegen": "wrangler types --env-interface CloudflareBindings" }, "dependencies": { "hono": "^4.9.6", "types": "../types" }, "devDependencies": { "@cloudflare/workers-types": "^4.0.0", "wrangler": "^4.4.0" } } 
Enter fullscreen mode Exit fullscreen mode

tsconfig.json

{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ES2022"], "types": ["@cloudflare/workers-types"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "@/*": ["src/*"], "@/types": ["../../types/index"] }, "noEmit": true, }, "include": ["src/**/*"], // ONLY server files "exclude": ["node_modules", "dist", "../client"] // Explicitly exclude client } 
Enter fullscreen mode Exit fullscreen mode

wrangler.jsonc

{ "$schema": "node_modules/wrangler/config-schema.json", "name": "server", "main": "src/index.ts", "compatibility_date": "2025-09-11", "pages_build_output_dir": "./dist" // for cloudflare pages main for workers // "compatibility_flags": [ // "nodejs_compat" // ], // "vars": { // "MY_VAR": "my-variable" // }, // "kv_namespaces": [ // { // "binding": "MY_KV_NAMESPACE", // "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" // } // ], // "r2_buckets": [ // { // "binding": "MY_BUCKET", // "bucket_name": "my-bucket" // } // ], // "d1_databases": [ // { // "binding": "MY_DB", // "database_name": "my-database", // "database_id": "" // } // ], // "ai": { // "binding": "AI" // }, // "observability": { // "enabled": true, // "head_sampling_rate": 1 // } } 
Enter fullscreen mode Exit fullscreen mode

src/index.ts

Here we need two things, a way to define our backend APIs which is done by using Hono, the other configurations is to enable Server-side rendering via React and Vite.

import { Hono } from "hono"; import { serveStatic } from "hono/cloudflare-workers"; import { cors } from "hono/cors"; const app = new Hono(); // Create a simple manifest object const staticManifest = {}; // Cross origin app.use( "/api/*", cors({ origin: ["http://localhost:3000", "http://localhost:5173"], // Allow both client ports credentials: true, }) ); // Serve static assets from the client build app.use( "/assets/*", serveStatic({ root: "./dist/client", manifest: staticManifest, }) ); app.use( "/favicon.ico", serveStatic({ path: "./dist/client/favicon.ico", manifest: staticManifest, }) ); app.get("/api/hello", (c) => { return c.json({ message: "Hello from Hono API!", timestamp: new Date().toISOString(), }); }); app.get("*", async (c) => { try { // Dynamically import the built server entry // @ts-ignore - This file is generated during build const { render } = await import("../dist/server/entry-server.js"); const url = new URL(c.req.url); const { html, state } = await render(url.pathname, {}); const template = ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Hono + React + Vite</title> </head> <body> <div id="root">${html}</div> <script>window.__INITIAL_STATE__ = ${state}</script> <script type="module" src="/assets/client.js"></script> </body> </html>`; return c.html(template); } catch (e) { console.error("SSR Error:", e); return c.html("Server Error", 500); } }); export default app; 
Enter fullscreen mode Exit fullscreen mode

Packages → Client

We create the client using:

cd packages npm create vite@latest 
Enter fullscreen mode Exit fullscreen mode

Then update tsconfig.app.json to add type paths.

pages/HomePage.tsx

This isn’t a Next.js pages folder — just a way to organize our code.

We’re using react-router for routing to get a SPA-like feel with SSR capability.

import { useQuery } from '@tanstack/react-query' import { apiFetch } from '../api'; function HomePage() { const { data: hello, isLoading } = useQuery({ queryKey: ['hello'], queryFn: async () => { const response = await apiFetch('/api/hello') if (!response.ok) { throw new Error('Network response was not ok') } return response.json() } }); return ( <div className="home-container"> <div className="text-center"> <h1 className="main-title"> Welcome to the Modern Stack </h1> <p className="main-description"> Experience NextJS-like DX with Hono, Cloudflare Workers, Vite, and React. Built for speed, deployed to the edge. </p> </div> <div className="features-grid"> <FeatureCard title="⚡ Lightning Fast" description="Powered by Cloudflare Workers with global edge deployment" /> <FeatureCard title="🔥 Hot Reload" description="Instant updates during development with Vite" /> <FeatureCard title="🛡️ Type Safe" description="Full TypeScript support across client and server" /> <FeatureCard title="🌐 Edge Computing" description="Run code closer to your users for minimal latency" /> <FeatureCard title="📦 Zero Config" description="Sensible defaults with easy customization" /> <FeatureCard title="🚀 Serverless" description="No servers to manage, scales automatically" /> </div> {/* API Status */} <div className="api-status-card"> <h3 className="api-status-title"> API Status </h3> {isLoading ? ( <div className="loading-pulse"> <div className="pulse-line pulse-line-1"></div> <div className="pulse-line pulse-line-2"></div> </div> ) : hello ? ( <div className="api-status-content"> <div className="status-indicator"> <div className="status-dot status-connected"></div> <span className="status-text">Connected to Hono API</span> </div> <p className="api-response"> Response: {hello.message} </p> <p className="api-timestamp"> Timestamp: {hello.timestamp} </p> </div> ) : ( <div className="status-indicator"> <div className="status-dot status-error"></div> <span className="status-text">API connection failed</span> </div> )} </div> </div> ) } function FeatureCard({ title, description }: { title: string; description: string }) { return ( <div className="feature-card"> <h3 className="feature-title">{title}</h3> <p className="feature-description">{description}</p> </div> ) } export default HomePage 
Enter fullscreen mode Exit fullscreen mode

client/src/entry-server.tsx

Handles server-side rendering for client routes.

import React from 'react' import { renderToString } from 'react-dom/server' import { StaticRouter } from 'react-router-dom/server' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' export function render(url: string, initialState : any) { // Create a new QueryClient for each request const queryClient = new QueryClient() const html = renderToString( <React.StrictMode> <StaticRouter location={url}> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> </StaticRouter> </React.StrictMode> ) // Serialize the state for hydration const state = JSON.stringify(initialState).replace(/</g, '\\\\u003c') return { html, state } } 
Enter fullscreen mode Exit fullscreen mode

We configure React Query for state management as well React Router For client side routing client/src/index.tsx:

import React from 'react' import { hydrateRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App' // Create a client for hydration const queryClient = new QueryClient() hydrateRoot( document.getElementById('root') as HTMLElement, <React.StrictMode> <BrowserRouter> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider> </BrowserRouter> </React.StrictMode> ) 
Enter fullscreen mode Exit fullscreen mode

CSS found in the link of repo below ...


Done. Seriously.

That’s it — we’ve just built a full-stack, serverless JavaScript framework ready to power production apps.

To get started:

npm i npm run build npm run dev 
Enter fullscreen mode Exit fullscreen mode

Node version: v20.17.0

Link of full source code : https://github.com/ARAldhafeeri/hono-react-vite-cloudflare

You now have a modern, serverless stack that is fast, scalable, and developer-friendly — without being tied down by commercial platforms.

Top comments (0)