Full-Stack Blog with Rust on Cloudflare

Build a blog platform with a Rust WASM API, Next.js SSR frontend, and D1 database.

Introduction

30 Second Summary

Every developer needs a place to share what they're learning. A personal blog that loads instantly from anywhere in the world proves you can build for real users.

In this project, you will build a full-stack blog platform using Rust for the backend API and Next.js for the frontend, deployed entirely for free on Cloudflare Workers.

What You'll Build

A portfolio-ready blogging platform with a Rust-powered REST API compiled to WebAssembly, a server-rendered Next.js frontend, a serverless SQLite database, and media uploads via Cloudinary.

By the end of this project, you'll have:

  • A Rust WASM Worker serving a JSON REST API for blog post CRUD operations, backed by a D1 serverless SQLite database.
  • A Next.js 15 frontend with server-side rendering deployed on Cloudflare Workers that lists and displays your blog posts.
  • A Cloudinary integration for uploading images and videos directly from the browser to your posts.
  • Secret Mission: Add markdown rendering with syntax highlighting to your blog posts.

Are there any prerequisites?

You should be familiar with Rust basics and JavaScript or TypeScript. You will also need a Cloudflare account, a Cloudinary account, the Rust toolchain, and Node.js installed on your machine.

Set Up the Rust Worker Project

Before you can build a blog API, you need the right tools in place. Rust compiles to WebAssembly for Cloudflare Workers, and Wrangler is the CLI that ties everything together.

In this step, you will scaffold a Rust Worker project, connect it to a D1 database, and prepare the schema for your blog posts.

In this step, get ready to:
  • Install the required Rust and Cloudflare tooling.
  • Scaffold the Rust Worker project and configure the D1 database binding.
  • Create and apply the blog posts database migration locally.
Install required tools and scaffold the Rust Worker project

Your Rust toolchain needs a few extras to target Cloudflare Workers. The wasm32-unknown-unknown target lets Rust compile to WebAssembly, and cargo-generate scaffolds projects from templates.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Add the WebAssembly compilation target to your Rust toolchain by running:
rustup target add wasm32-unknown-unknown
  • Install cargo-generate (the scaffolding tool) by running:
cargo install cargo-generate
  • Install the Wrangler CLI globally by running:
npm install -g wrangler

What do these commands do?

rustup target add wasm32-unknown-unknown teaches your Rust compiler how to output WebAssembly binaries instead of native executables.

cargo-generate creates new Rust projects from Git repository templates. Wrangler is Cloudflare's CLI for managing Workers, D1 databases, and deployments.

Seeing permission errors on npm install -g?

On macOS or Linux, you may need to prefix the command with sudo. Alternatively, configure npm to install global packages in your home directory.

Help me fix npm global install permissions.

  • Scaffold your Rust Worker project by running:
cargo generate cloudflare/workers-rs

What will this prompt me for?

The generator will ask for a project name. Enter blog-api when prompted.

It may also ask which template to use. Select the hello-world template (the basic HTTP handler).

  • Move into the newly created project folder by running:
cd blog-api
  • Confirm the project files exist by running:
ls

You should see files including Cargo.toml, src/, and wrangler.toml in the output.

  • Sign in to your Cloudflare account through Wrangler by running:
npx wrangler login

What happens when I run this?

Wrangler opens your default browser to a Cloudflare authorization page. Click Allow to grant Wrangler access to your account.

If your browser does not open automatically, copy the URL shown in the terminal and paste it into your browser manually.

Having trouble logging in?

Make sure you are signed in to your Cloudflare account in your browser before running the command.

If the browser window does not appear, check that your default browser is set correctly in your system settings.

Help me troubleshoot the Wrangler login flow.

Configure the D1 database binding and create the database

Your Rust Worker needs a database to store blog posts. Cloudflare D1 is a serverless SQLite database that binds directly to Workers with zero connection strings.

  • Create the D1 database on your Cloudflare account by running:
npx wrangler d1 create blog-db

You should see output that includes a database_id value. Copy this ID from your terminal.

What does this command do?

This creates a new D1 database named blog-db on Cloudflare's network. The output gives you a unique database_id that connects your Worker to this specific database.

  • Open the blog-api folder in your editor by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Cursor (or VS Code), and pressing Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the blog-api folder on your Desktop, then click Open.
  • Open wrangler.toml from the file explorer sidebar.
  • Replace the entire contents of wrangler.toml with the following configuration:
name = "blog-api"
main = "build/worker/shim.mjs"
compatibility_date = "2024-01-01"

[build]
command = "cargo install -q worker-build && worker-build --release"

[[d1_databases]]
binding = "DB"
database_name = "blog-db"
database_id = "<your-database-id>"

What does this configuration do?

  • name sets your Worker's name on Cloudflare.
  • main points to the JavaScript shim that loads your compiled WASM binary.
  • [build] tells Wrangler to compile your Rust code to WebAssembly before deploying.
  • [[d1_databases]] creates a binding named DB that your Rust code uses to query the D1 database.
  • Replace <your-database-id> with the actual database_id you copied from the terminal output earlier.
  • Save wrangler.toml.

Don't have the database_id anymore?

Run npx wrangler d1 list in your terminal to see all your D1 databases and their IDs.

Help me find my D1 database ID.

Create and apply the blog posts database migration

D1 uses SQL migration files to define your database schema. Each migration is a versioned .sql file that Wrangler applies in order.

  • Create the migrations folder inside your blog-api project by running this in your terminal:
mkdir -p migrations
  • Create the migration file by running:
touch migrations/0001_create_posts.sql
  • Confirm the file was created by running:
ls migrations

You should see 0001_create_posts.sql in the output.

  • Open migrations/0001_create_posts.sql in your editor and paste the following schema:
CREATE TABLE IF NOT EXISTS posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  image_url TEXT,
  video_url TEXT,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

What does this schema do?

  • id auto-increments so each post gets a unique number.
  • title and content are required fields for every blog post.
  • image_url and video_url are optional fields for Cloudinary media attachments.
  • created_at and updated_at automatically record timestamps.
  • Save 0001_create_posts.sql.
  • Apply the migration to your local D1 database by running:
npx wrangler d1 migrations apply blog-db --local

What does --local mean?

The --local flag runs the migration against a local SQLite file in your .wrangler/state directory. This lets you develop and test without touching your production database on Cloudflare.

Wrangler will prompt you to confirm the migration. Type Yes and press Enter.

You should see a success message confirming the migration was applied.

Seeing an error when applying migrations?

Make sure you are running the command from inside the blog-api folder (where wrangler.toml lives).

Check that your wrangler.toml has the [[d1_databases]] section with database_name = "blog-db" matching the name in the command.

Help me fix my D1 migration error.

✔️ Awesome, I've got everything!

Great. Double check that you have saved both wrangler.toml and 0001_create_posts.sql.

ⓧ I'd like to double check the full code

Here is what your wrangler.toml should look like:

name = "blog-api"
main = "build/worker/shim.mjs"
compatibility_date = "2024-01-01"

[build]
command = "cargo install -q worker-build && worker-build --release"

[[d1_databases]]
binding = "DB"
database_name = "blog-db"
database_id = "<your-database-id>"

And here is what migrations/0001_create_posts.sql should look like:

CREATE TABLE IF NOT EXISTS posts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  image_url TEXT,
  video_url TEXT,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

Your Rust Worker project is scaffolded, connected to D1, and ready for code. Next up, you will build the actual blog API endpoints in Rust.

Build the Rust Blog API

Your Rust Worker project is scaffolded, your D1 database is created, and the posts schema is ready. Now it's time to write the actual API logic that handles blog post data.

In this step, you'll define the data models that represent a blog post, set up a Router to handle incoming HTTP requests, and wire everything to your D1 database so your API can create, list, and fetch individual posts.

In this step, get ready to:
  • Define data models and configure dependencies for JSON serialization.
  • Build the Router with D1 query handlers for three CRUD endpoints.
  • Test the API locally with curl to verify all endpoints return correct JSON.
Update dependencies and define data models

Before writing the API logic, you need two things: the serde crate for JSON serialization/deserialization, and the worker crate's d1 feature flag to access the database bindings.

  • In your editor, open Cargo.toml inside the blog-api folder.
  • Replace the entire contents with this configuration:
[package]
name = "blog-api"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
# Cloudflare Workers Rust bindings with D1 database support
worker = { version = "0.3", features = ["d1"] }
# JSON serialization and deserialization
serde = { version = "1", features = ["derive"] }
# JSON string utilities
serde_json = "1"

What does this configuration do?

The crate-type = ["cdylib"] tells Rust to compile your code as a C-compatible dynamic library. This is required for WebAssembly output that Cloudflare Workers can load.

The worker crate provides the Router, Request/Response types, and D1 bindings. The d1 feature flag enables database access specifically.

  • Save Cargo.toml.

Now you'll define the data models that represent blog posts in your API. These structs map directly to the columns in your D1 posts table.

  • Open src/lib.rs inside the blog-api folder.
  • Delete all the existing scaffold code and replace it with these imports and struct definitions:
use worker::*;
use serde::{Deserialize, Serialize};

// Represents a blog post stored in D1
#[derive(Serialize, Deserialize, Debug)]
pub struct Post {
    pub id: Option<i64>,
    pub title: String,
    pub content: String,
    pub image_url: Option<String>,
    pub video_url: Option<String>,
    pub created_at: Option<String>,
}

// Represents the JSON body when creating a new post
#[derive(Deserialize)]
pub struct CreatePostRequest {
    pub title: String,
    pub content: String,
    pub image_url: Option<String>,
    pub video_url: Option<String>,
}

What do these structs do?

The Post struct maps to rows in your posts table. Fields like id and created_at are Option types because D1 auto-generates them. They won't exist when creating a new post.

The CreatePostRequest struct only derives Deserialize because you only need to parse incoming JSON. You never serialize it back to a response.

  • Save src/lib.rs.

Seeing a compile error about missing derive macros?

Make sure your Cargo.toml has features = ["derive"] on the serde dependency. Without the derive feature, #[derive(Serialize, Deserialize)] won't resolve.

Help me fix my serde derive error in Cargo.toml

Write the router with D1 query handlers

The worker crate provides a Router that maps HTTP methods and paths to async handler functions. Each handler receives the request and a context object that gives access to your D1 binding.

  • In src/lib.rs, add the following main function below your struct definitions:
#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    let router = Router::new();
    router
        // List all posts, newest first
        .get_async("/posts", |_, ctx| async move {
            let db = ctx.env.d1("DB")?;
            let stmt = db.prepare("SELECT id, title, content, image_url, video_url, created_at FROM posts ORDER BY created_at DESC");
            let result = stmt.all().await?;
            let posts = result.results::<Post>()?;
            Response::from_json(&posts)
        })
        // Fetch a single post by ID
        .get_async("/posts/:id", |_, ctx| async move {
            let id = ctx.param("id").unwrap();
            let db = ctx.env.d1("DB")?;
            let stmt = db.prepare("SELECT id, title, content, image_url, video_url, created_at FROM posts WHERE id = ?1").bind(&[id.into()])?;
            let post = stmt.first::<Post>(None).await?;
            match post {
                Some(p) => Response::from_json(&p),
                None => Response::error("Not Found", 404),
            }
        })
        // Create a new post
        .post_async("/posts", |mut req, ctx| async move {
            let body: CreatePostRequest = req.json().await?;
            let db = ctx.env.d1("DB")?;
            let stmt = db.prepare("INSERT INTO posts (title, content, image_url, video_url) VALUES (?1, ?2, ?3, ?4)")
                .bind(&[body.title.into(), body.content.into(), body.image_url.unwrap_or_default().into(), body.video_url.unwrap_or_default().into()])?;
            stmt.run().await?;
            Response::ok("Post created")
        })
        .run(req, env)
        .await
}

What does this code do?

The #[event(fetch)] macro registers this function as the Worker's HTTP entry point. Every incoming request flows through it.

Each route handler calls ctx.env.d1("DB") to access the D1 binding you configured in wrangler.toml. The "DB" string matches the binding name from your configuration.

For the single-post route, ctx.param("id") extracts the URL parameter. The ?1 syntax in SQL is a positional placeholder that .bind() fills with the actual value, preventing SQL injection.

  • Save src/lib.rs.

Seeing unresolved import errors?

Make sure the very first line of src/lib.rs is use worker::*; which brings all the Worker types (Router, Request, Response, Env, Context) into scope.

Help me fix unresolved import errors in my Rust Worker

✔️ Awesome, I've got everything!

Great. Double check you've saved both Cargo.toml and src/lib.rs.

ⓧ I'd like to double check the full code

Here is the complete src/lib.rs file:

use worker::*;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
pub struct Post {
    pub id: Option<i64>,
    pub title: String,
    pub content: String,
    pub image_url: Option<String>,
    pub video_url: Option<String>,
    pub created_at: Option<String>,
}

#[derive(Deserialize)]
pub struct CreatePostRequest {
    pub title: String,
    pub content: String,
    pub image_url: Option<String>,
    pub video_url: Option<String>,
}

#[event(fetch)]
pub async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    let router = Router::new();
    router
        .get_async("/posts", |_, ctx| async move {
            let db = ctx.env.d1("DB")?;
            let stmt = db.prepare("SELECT id, title, content, image_url, video_url, created_at FROM posts ORDER BY created_at DESC");
            let result = stmt.all().await?;
            let posts = result.results::<Post>()?;
            Response::from_json(&posts)
        })
        .get_async("/posts/:id", |_, ctx| async move {
            let id = ctx.param("id").unwrap();
            let db = ctx.env.d1("DB")?;
            let stmt = db.prepare("SELECT id, title, content, image_url, video_url, created_at FROM posts WHERE id = ?1").bind(&[id.into()])?;
            let post = stmt.first::<Post>(None).await?;
            match post {
                Some(p) => Response::from_json(&p),
                None => Response::error("Not Found", 404),
            }
        })
        .post_async("/posts", |mut req, ctx| async move {
            let body: CreatePostRequest = req.json().await?;
            let db = ctx.env.d1("DB")?;
            let stmt = db.prepare("INSERT INTO posts (title, content, image_url, video_url) VALUES (?1, ?2, ?3, ?4)")
                .bind(&[body.title.into(), body.content.into(), body.image_url.unwrap_or_default().into(), body.video_url.unwrap_or_default().into()])?;
            stmt.run().await?;
            Response::ok("Post created")
        })
        .run(req, env)
        .await
}

And the complete Cargo.toml:

[package]
name = "blog-api"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
worker = { version = "0.3", features = ["d1"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Test the API locally with wrangler dev

Your code is written. Time to compile the Rust to WebAssembly and spin up a local dev server that simulates the Cloudflare Workers runtime, including your D1 database.

  • In your terminal, make sure you are inside the blog-api folder.
  • Start the local development server by running:
npx wrangler dev

What should I see?

Wrangler compiles your Rust code to WASM (this may take a minute on the first run), then starts a local server. You should see output indicating the server is ready at http://localhost:8787.

Build failed with a compile error?

Rust compilation errors will appear in the terminal output. Check for typos in struct field names, missing commas between struct fields, or mismatched braces in the router closures.

Help me debug my Rust Worker compile error

With the server running, open a second terminal window to test each endpoint.

  • Create a test post by running:
curl -X POST http://localhost:8787/posts -H "Content-Type: application/json" -d '{"title":"My First Post","content":"Hello from Rust on Cloudflare Workers!"}'
  • You should see Post created in the response.
  • List all posts by running:
curl http://localhost:8787/posts
  • You should see a JSON array containing your test post, with an id, title, content, and created_at fields populated.
  • Fetch the single post by its ID:
curl http://localhost:8787/posts/1
  • You should see a single JSON object with the post you just created.

Getting an empty array from /posts?

Make sure you ran the POST request first. Also confirm that your D1 migration was applied locally in the previous step. If needed, re-apply it by running:

npx wrangler d1 migrations apply blog-db --local

Help me troubleshoot empty responses from my Rust Worker API

Your Rust blog API is alive and responding with real data from D1. Next up, you'll build the Next.js frontend that consumes this API and renders your blog posts with server-side rendering.

Set Up the Next.js Frontend on Cloudflare

Your Rust API is tested and serving blog posts from D1. Now it needs a frontend that visitors can see and interact with.

In this step, you'll scaffold a Next.js 15 project configured for Cloudflare Workers using the OpenNext adapter. You'll set up the App Router, point it at your Rust Worker's local URL, and build a blog listing page that fetches posts with server-side rendering.

In this step, get ready to:
  • Scaffold the Next.js project with the Cloudflare CLI.
  • Configure the app layout and API environment variable.
  • Build the blog listing page as a Server Component with SSR.
Scaffold the Next.js project

Cloudflare provides a CLI tool called C3 (create-cloudflare) that scaffolds framework projects pre-configured for Workers. It runs Next.js's own setup tool under the hood, then adds the OpenNext adapter so your app can deploy to the edge.

  • In your terminal, navigate back to your Desktop (the parent of blog-api) by running this command:
cd ~/Desktop
  • Scaffold the Next.js frontend project by running this command:
npm create cloudflare@latest -- blog-frontend --framework=next

What does this command do?

C3 creates a new blog-frontend directory, runs Next.js's official setup tool inside it, and configures the project for Cloudflare Workers with the OpenNext adapter.

The --framework=next flag tells C3 to use the Next.js generator directly, giving you the same options you'd get from create-next-app.

What prompts will I see?

The scaffold runs Next.js's interactive setup. Accept the defaults for TypeScript, ESLint, Tailwind CSS, and App Router when prompted.

At the end, C3 will ask if you want to deploy your application. Choose No for now. You'll deploy in a later step.

  • Once the scaffold finishes, move into the new project folder by running:
cd blog-frontend
  • Confirm the project files exist by running:
ls
  • You should see app, package.json, node_modules, and other project files in the output.

Command not found or scaffold failed?

  • Make sure Node.js is installed by running node --version. You need Node 16.17.0 or later.
  • If the command hangs at "installing dependencies", your network may be slow. Wait a few minutes for npm to finish downloading packages.
  • help me troubleshoot my Next.js scaffold on Cloudflare
Configure the layout and environment variable

The scaffold generated a default layout and homepage. You need to point the frontend at your Rust Worker's local URL so it can fetch blog posts during development.

  • Create the environment file by running this command:
echo "NEXT_PUBLIC_API_URL=http://localhost:8787" > .env.local

What is .env.local?

Next.js automatically loads variables from .env.local into your application. The NEXT_PUBLIC_ prefix makes the variable available in both server and client components.

Port 8787 is where your Rust Worker runs locally via npx wrangler dev.

  • Now replace the contents of app/layout.tsx with the blog layout. Open app/layout.tsx in your editor and replace everything with this code:
import type { Metadata } from 'next';
import './globals.css';

// Metadata shown in browser tab and search results
export const metadata: Metadata = {
  title: 'My Blog',
  description: 'A full-stack blog powered by Rust and Next.js on Cloudflare',
};

// Root layout wraps every page in the app
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What does this layout do?

  • The metadata export sets the page title and description that appear in the browser tab and search engine results.
  • The RootLayout component wraps every page in your app with a standard HTML shell. Next.js's App Router requires this file.
  • Save app/layout.tsx.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/layout.tsx and the .env.local file.

ⓧ I'd like to double check the full code

Here is what app/layout.tsx should look like:

import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: 'My Blog',
  description: 'A full-stack blog powered by Rust and Next.js on Cloudflare',
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

And .env.local should contain:

NEXT_PUBLIC_API_URL=http://localhost:8787
Build the blog listing page

The homepage will be an async Server Component that fetches posts from your Rust Worker at build/request time. Because it runs on the server, the API call never touches the browser.

  • Open app/page.tsx in your editor and replace everything with this code:
import Link from 'next/link';

// Type definition matching the Rust API's Post struct
interface Post {
  id: number;
  title: string;
  content: string;
  image_url?: string;
  created_at: string;
}

// Server-side function that fetches posts from the Rust Worker API
async function getPosts(): Promise<Post[]> {
  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/posts`, { cache: 'no-store' });
  if (!res.ok) return [];
  return res.json();
}

// Async Server Component - runs on the server, not in the browser
export default async function HomePage() {
  const posts = await getPosts();
  return (
    <main>
      <h1>My Blog</h1>
      <Link href="/posts/new">Create New Post</Link>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link href={`/posts/${post.id}`}>
              <h2>{post.title}</h2>
            </Link>
            <p>{new Date(post.created_at).toLocaleDateString()}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

What does this page do?

  • The Post interface mirrors the shape of data returned by your Rust API. This gives you type safety in TypeScript.
  • The getPosts() function calls your Rust Worker's /posts endpoint. The cache: 'no-store' option ensures fresh data on every request (SSR, not static generation).
  • The HomePage component is an async function. This makes it a React Server Component that fetches data on the server before sending HTML to the browser.
  • Save app/page.tsx.

✔️ Awesome, I've got everything!

Great. Make sure you've saved app/page.tsx before moving on.

ⓧ I'd like to double check the full code

Here is what app/page.tsx should look like:

import Link from 'next/link';

interface Post {
  id: number;
  title: string;
  content: string;
  image_url?: string;
  created_at: string;
}

async function getPosts(): Promise<Post[]> {
  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/posts`, { cache: 'no-store' });
  if (!res.ok) return [];
  return res.json();
}

export default async function HomePage() {
  const posts = await getPosts();
  return (
    <main>
      <h1>My Blog</h1>
      <Link href="/posts/new">Create New Post</Link>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link href={`/posts/${post.id}`}>
              <h2>{post.title}</h2>
            </Link>
            <p>{new Date(post.created_at).toLocaleDateString()}</p>
          </li>
        ))}
      </ul>
    </main>
  );
}

Now test that the frontend can talk to your Rust API. You need both servers running at the same time.

  • Open a second terminal tab or window. Navigate to your blog-api folder and start the Rust Worker by running:
cd ~/Desktop/blog-api
npx wrangler dev
  • Switch back to your first terminal (which should be in blog-frontend). Start the Next.js dev server by running:
npm run dev
  • Open your browser and go to http://localhost:3000. You should see the "My Blog" heading and a "Create New Post" link.
  • If you created test posts in the previous step, they appear as a list below the heading.

Page loads but no posts appear?

  • Confirm the Rust API is running on port 8787 in your other terminal. You should see Wrangler's local dev output there.
  • Check that .env.local contains exactly NEXT_PUBLIC_API_URL=http://localhost:8787 with no trailing slash.
  • If you changed .env.local after starting the dev server, stop it with Ctrl+C and run npm run dev again. Next.js only reads env files at startup.
  • help me debug why my Next.js page cannot fetch from the Rust Worker API

Your frontend is fetching data from the Rust API and rendering it with server-side rendering. Next up, you'll build the individual post page and the post creation form.

Build the Post Detail and Creation Pages

Your blog listing page can show all your posts, but clicking one leads nowhere yet. Readers need a dedicated page for each post that displays the full content, featured media, and metadata.

In this step, you will build two new pages. A dynamic detail page that fetches a single post by its ID from the Rust API, and a creation page with a form that submits new posts back to the API.

In this step, get ready to:
  • Create a dynamic route that renders a single blog post with its title, date, image, and video.
  • Build a post creation page with a form for entering title, content, and media URLs.
  • Wire up the form to submit data to the Rust Worker API and verify the full flow end-to-end.
Create the post detail page

The Next.js App Router uses folder-based dynamic segments to map URLs like /posts/3 to a page component. The [id] folder captures the post ID from the URL and passes it to your page as a parameter.

  • Create the nested folder structure for the dynamic route by running these commands from inside your blog-frontend directory:
mkdir -p app/posts/[id]
  • Create the post detail page file by adding app/posts/[id]/page.tsx with the data fetching layer and metadata generator. Copy the following code into the new file:
interface Post {
  id: number;
  title: string;
  content: string;
  image_url?: string;
  video_url?: string;
  created_at: string;
}

// Fetch a single post by ID from the Rust API
async function getPost(id: string): Promise<Post | null> {
  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/posts/${id}`, { cache: 'no-store' });
  if (!res.ok) return null;
  return res.json();
}

// Generate dynamic page metadata using the post title
export async function generateMetadata({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);
  return { title: post?.title ?? 'Post Not Found' };
}

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);
  if (!post) return <div>Post not found.</div>;
  return (
    <main>
      <h1>{post.title}</h1>
      <p>{new Date(post.created_at).toLocaleDateString()}</p>
      {post.image_url && <img src={post.image_url} alt="Post image" />}
      {post.video_url && <video src={post.video_url} controls />}
      <p>{post.content}</p>
    </main>
  );
}

What does this code do?

This file has three parts that work together to render a single blog post.

  • getPost(id) calls the Rust API's GET /posts/:id endpoint and returns the post data or null if not found.
  • generateMetadata sets the browser tab title dynamically to the post's title. Next.js calls this function on the server before rendering.
  • PostPage is a Server Component that renders the post title, date, optional image/video, and content. If the post doesn't exist, it shows a fallback message.

What is a dynamic route?

A dynamic route in the App Router is a folder wrapped in square brackets, like [id]. When someone visits /posts/5, Next.js fills in params.id with "5" and passes it to your page component. One file handles every post.

  • Save app/posts/[id]/page.tsx.
  • You won't be able to test this page until you have a post in the database. The next substep creates the form to add posts.
Build the post creation form

Readers need a way to create new posts. This page uses a Client Component because it manages form state and handles user interaction in the browser.

  • Create the folder for the creation route by running:
mkdir -p app/posts/new
  • Create app/posts/new/page.tsx with the full form component. Copy the following code into the new file:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function NewPostPage() {
  const router = useRouter();
  // Form state for each field
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [imageUrl, setImageUrl] = useState('');
  const [videoUrl, setVideoUrl] = useState('');

  // Submit the form data to the Rust Worker API
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    await fetch(`${process.env.NEXT_PUBLIC_API_URL}/posts`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, content, image_url: imageUrl, video_url: videoUrl }),
    });
    router.push('/');
  }

  return (
    <main>
      <h1>Create New Post</h1>
      <form onSubmit={handleSubmit}>
        <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" required />
        <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="Content" required />
        <input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="Image URL" />
        <input value={videoUrl} onChange={(e) => setVideoUrl(e.target.value)} placeholder="Video URL" />
        <button type="submit">Publish</button>
      </form>
    </main>
  );
}

What does this code do?

This is a Client Component (marked with 'use client') because it manages interactive state in the browser.

  • useState hooks track each form field's current value as the user types.
  • handleSubmit sends a POST request to the Rust API at /posts with the title, content, and optional media URLs as JSON.
  • router.push('/') redirects the user back to the blog listing after a successful submission.
  • Save app/posts/new/page.tsx.

✔️ Awesome, I've got everything!

Great. Double check you have saved both new files before moving on.

ⓧ I'd like to double check the full code

Here is the full app/posts/[id]/page.tsx:

interface Post {
  id: number;
  title: string;
  content: string;
  image_url?: string;
  video_url?: string;
  created_at: string;
}

async function getPost(id: string): Promise<Post | null> {
  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/posts/${id}`, { cache: 'no-store' });
  if (!res.ok) return null;
  return res.json();
}

export async function generateMetadata({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);
  return { title: post?.title ?? 'Post Not Found' };
}

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);
  if (!post) return <div>Post not found.</div>;
  return (
    <main>
      <h1>{post.title}</h1>
      <p>{new Date(post.created_at).toLocaleDateString()}</p>
      {post.image_url && <img src={post.image_url} alt="Post image" />}
      {post.video_url && <video src={post.video_url} controls />}
      <p>{post.content}</p>
    </main>
  );
}

And here is the full app/posts/new/page.tsx:

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function NewPostPage() {
  const router = useRouter();
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [imageUrl, setImageUrl] = useState('');
  const [videoUrl, setVideoUrl] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    await fetch(`${process.env.NEXT_PUBLIC_API_URL}/posts`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, content, image_url: imageUrl, video_url: videoUrl }),
    });
    router.push('/');
  }

  return (
    <main>
      <h1>Create New Post</h1>
      <form onSubmit={handleSubmit}>
        <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" required />
        <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="Content" required />
        <input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="Image URL" />
        <input value={videoUrl} onChange={(e) => setVideoUrl(e.target.value)} placeholder="Video URL" />
        <button type="submit">Publish</button>
      </form>
    </main>
  );
}

Seeing module not found errors?

  • Make sure the file is saved as page.tsx (not .ts or .js). The .tsx extension is required because the file contains JSX.
  • Check that the folder is named exactly [id] with square brackets. Without brackets, Next.js treats it as a static route.
  • Confirm your folder structure is app/posts/[id]/page.tsx and app/posts/new/page.tsx. Both need to be inside app/posts/.

Why aren't my new pages showing up in Next.js?

Test the form submission end-to-end

Both pages are ready. Now you will create a post through the form and verify it appears on the detail page.

  • Make sure your Rust API is still running locally. If it stopped, switch to your blog-api directory and start it by running:
npx wrangler dev
  • In a separate terminal, switch to your blog-frontend directory and start the Next.js dev server by running:
npm run dev
  • Open your browser and navigate to http://localhost:3000/posts/new.
  • You should see a form with fields for Title, Content, Image URL, and Video URL, plus a Publish button.
  • Enter My First Post in the Title field.
  • Enter Hello from my Rust-powered blog! in the Content field.
  • Leave the Image URL and Video URL fields empty for now.
  • Click Publish.

You should be redirected to the home page at http://localhost:3000. Your new post should appear in the listing with the title and date.

  • Click the post title link in the listing to navigate to the detail page.
  • You should see the full post rendered with the title, date, and content. The browser tab title should show the post's title.

Post not appearing after submit?

  • Check that your Rust API dev server is running on port 8787. The form submits to the URL defined in .env.local.
  • Open your browser's DevTools Network tab and look for the POST /posts request. If you see a CORS error, make sure you are accessing the Next.js app at http://localhost:3000 (not 127.0.0.1).
  • Verify that NEXT_PUBLIC_API_URL in .env.local is set to http://localhost:8787 with no trailing slash.

My blog form submits but the post doesn't show up. Help me debug.

Your blog now has working pages for reading and writing posts. Next up, you will integrate Cloudinary so authors can upload images and videos directly from the creation form.

Integrate Cloudinary for Media Uploads

Your post creation form currently requires manually pasting image and video URLs. That works for testing, but a real blog needs drag-and-drop media uploads.

In this step, you'll integrate Cloudinary to handle media uploads directly from the browser. You'll create an unsigned upload preset, build a reusable upload component, and wire it into your post form so that media files get hosted on Cloudinary's CDN automatically.

In this step, get ready to:
  • Create an unsigned upload preset in the Cloudinary Console.
  • Build a reusable MediaUpload component that uploads files to Cloudinary.
  • Connect the upload component to your post creation form.
Create an unsigned upload preset in Cloudinary

Cloudinary upload presets define how uploaded files are processed and stored. An unsigned preset allows your browser-side code to upload directly to Cloudinary without needing a server-side signature.

  • Open your browser and go to https://console.cloudinary.com.
  • Sign in to your Cloudinary account.
  • On the Dashboard, look for the Product Environment section at the top of the page.
  • Copy your Cloud Name value and save it somewhere handy. You'll need it in a moment.

What is a cloud name?

Your cloud name is a unique identifier for your Cloudinary product environment. It appears in every media URL that Cloudinary delivers. It's safe to expose in client-side code.

  • Click the Settings icon in the left sidebar.
  • Navigate to the Upload page.
  • Select the Upload Presets tab.
  • Click Add Upload Preset.
  • Set Signing Mode to Unsigned.
  • Give the preset a name you'll remember (for example, blog_unsigned).
  • Click Save.
  • Copy the preset name you just created.

Why use an unsigned preset?

Unsigned presets allow uploads directly from the browser without generating an authentication signature on the server. For a personal portfolio blog where you control who posts, this is the simplest approach with minimal security risk.

The preset restricts what operations can be performed, so even if someone discovers the preset name in your client-side code, they can only upload files according to the rules you defined.

Now add your Cloudinary credentials to the Next.js environment variables.

  • Switch back to your code editor and open blog-frontend/.env.local.
  • Add these two lines below the existing NEXT_PUBLIC_API_URL line:
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=[[CLOUD_NAME="your Cloudinary cloud name"]]
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=[[UPLOAD_PRESET="your upload preset name"]]

What do these environment variables do?

Both variables are prefixed with NEXT_PUBLIC_ so Next.js exposes them to the browser. The upload component needs these values to send files directly from the learner's browser to Cloudinary's API.

  • Save .env.local.

Uploads failing later with 401 or 404?

Double-check that your cloud name matches exactly what appears on the Cloudinary Dashboard (case-sensitive). Confirm the preset name matches what you saved in the Upload Presets page.

If you changed these values, restart your Next.js dev server for the environment variables to reload.

Help me debug my Cloudinary environment variables.

Build the MediaUpload component

You'll create a reusable Client Component that handles file selection, uploads the file to Cloudinary's upload endpoint using the Fetch API with FormData, and returns the hosted URL.

  • Create a new folder called components inside your blog-frontend directory.
  • Create a new file called MediaUpload.tsx inside the components folder.
  • Add the following code to MediaUpload.tsx:
'use client';
import { useState } from 'react';

interface MediaUploadProps {
  label: string;
  resourceType: 'image' | 'video';
  onUpload: (url: string) => void;
}

export default function MediaUpload({ label, resourceType, onUpload }: MediaUploadProps) {
  const [uploading, setUploading] = useState(false);
  const [preview, setPreview] = useState<string | null>(null);

  async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    setUploading(true);
    // Build the FormData payload for Cloudinary's upload endpoint
    const formData = new FormData();
    formData.append('file', file);
    formData.append('upload_preset', process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET!);
    // POST directly to Cloudinary's REST API from the browser
    const res = await fetch(
      `https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}/${resourceType}/upload`,
      { method: 'POST', body: formData }
    );
    const data = await res.json();
    // Use the secure_url from Cloudinary's response
    setPreview(data.secure_url);
    onUpload(data.secure_url);
    setUploading(false);
  }

  return (
    <div>
      <label>{label}</label>
      <input type="file" accept={resourceType === 'image' ? 'image/*' : 'video/*'} onChange={handleFileChange} />
      {uploading && <span>Uploading...</span>}
      {preview && resourceType === 'image' && <img src={preview} alt="Preview" width={200} />}
      {preview && resourceType === 'video' && <video src={preview} controls width={300} />}
    </div>
  );
}

What does this component do?

  • It accepts a label, a resourceType (image or video), and an onUpload callback.
  • When the user selects a file, it builds a FormData object with the file and the upload preset name.
  • It POSTs the FormData directly to https://api.cloudinary.com/v1_1/{cloud_name}/{resourceType}/upload. No server-side proxy needed.
  • Cloudinary responds with a JSON object containing secure_url. The component passes this URL back to the parent form via the onUpload callback.
  • A preview (image thumbnail or video player) renders once the upload completes.
  • Save components/MediaUpload.tsx.

Seeing a TypeScript error about the component?

Make sure your file is inside blog-frontend/components/ (not inside app/). The import path @/components/MediaUpload expects this exact location.

Help me fix my MediaUpload component import.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

Here is the complete components/MediaUpload.tsx:

'use client';
import { useState } from 'react';

interface MediaUploadProps {
  label: string;
  resourceType: 'image' | 'video';
  onUpload: (url: string) => void;
}

export default function MediaUpload({ label, resourceType, onUpload }: MediaUploadProps) {
  const [uploading, setUploading] = useState(false);
  const [preview, setPreview] = useState<string | null>(null);

  async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    setUploading(true);
    const formData = new FormData();
    formData.append('file', file);
    formData.append('upload_preset', process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET!);
    const res = await fetch(
      `https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}/${resourceType}/upload`,
      { method: 'POST', body: formData }
    );
    const data = await res.json();
    setPreview(data.secure_url);
    onUpload(data.secure_url);
    setUploading(false);
  }

  return (
    <div>
      <label>{label}</label>
      <input type="file" accept={resourceType === 'image' ? 'image/*' : 'video/*'} onChange={handleFileChange} />
      {uploading && <span>Uploading...</span>}
      {preview && resourceType === 'image' && <img src={preview} alt="Preview" width={200} />}
      {preview && resourceType === 'video' && <video src={preview} controls width={300} />}
    </div>
  );
}
Connect the upload component to the post creation form

Now you'll replace the manual URL input fields in your post creation page with the new MediaUpload component. The uploaded Cloudinary URLs will flow into your form state and get submitted to the Rust API.

  • Open blog-frontend/app/posts/new/page.tsx.
  • Find the import lines at the top of the file:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
  • Add the MediaUpload import below the useRouter import, so the imports look like this:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import MediaUpload from '@/components/MediaUpload';
  • Scroll down to the form's JSX. Find these two input lines:
        <input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="Image URL" />
        <input value={videoUrl} onChange={(e) => setVideoUrl(e.target.value)} placeholder="Video URL" />
  • Replace those two lines with the MediaUpload components:
        <MediaUpload label="Upload Image" resourceType="image" onUpload={setImageUrl} />
        <MediaUpload label="Upload Video" resourceType="video" onUpload={setVideoUrl} />

How does this connect everything?

Each MediaUpload component passes the Cloudinary secure_url back to the parent form through setImageUrl or setVideoUrl. When the form submits, those URLs get sent to your Rust API's POST /posts endpoint as image_url and video_url. The Rust Worker stores them in D1.

The flow is: browser file picker > Cloudinary CDN > secure_url back to form state > JSON body to Rust API > D1 database.

  • Save app/posts/new/page.tsx.
  • Make sure your Next.js dev server is running. If it's stopped, start it again from the blog-frontend directory.
  • Open http://localhost:3000/posts/new in your browser.
  • You should see file input fields labeled Upload Image and Upload Video instead of the old text inputs.
  • Select an image file. After a moment, you should see a preview thumbnail appear below the input.

No preview appearing after selecting a file?

Open the browser DevTools console and check for errors. A 401 error means your upload preset's signing mode is not set to Unsigned. A 404 error usually means your cloud name is wrong.

Make sure you restarted the Next.js dev server after editing .env.local. Environment variable changes require a server restart.

Help me debug my Cloudinary upload integration.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/posts/new/page.tsx:

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import MediaUpload from '@/components/MediaUpload';

export default function NewPostPage() {
  const router = useRouter();
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [imageUrl, setImageUrl] = useState('');
  const [videoUrl, setVideoUrl] = useState('');

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    await fetch(`${process.env.NEXT_PUBLIC_API_URL}/posts`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title, content, image_url: imageUrl, video_url: videoUrl }),
    });
    router.push('/');
  }

  return (
    <main>
      <h1>Create New Post</h1>
      <form onSubmit={handleSubmit}>
        <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" required />
        <textarea value={content} onChange={(e) => setContent(e.target.value)} placeholder="Content" required />
        <MediaUpload label="Upload Image" resourceType="image" onUpload={setImageUrl} />
        <MediaUpload label="Upload Video" resourceType="video" onUpload={setVideoUrl} />
        <button type="submit">Publish</button>
      </form>
    </main>
  );
}

And here is the complete .env.local:

NEXT_PUBLIC_API_URL=http://localhost:8787
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=<your-cloud-name>
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=<your-upload-preset>

Your blog now supports real media uploads. Next up, you'll deploy both the Rust API and Next.js frontend to Cloudflare's global edge network.

Deploy Both Services to Cloudflare

Everything works locally. Now it is time to push both services to Cloudflare's global edge network so the world can see your blog.

Deploying involves two separate Workers: the Rust API and the Next.js frontend. You will deploy the API first, grab its production URL, then build the frontend against that URL before deploying it too.

In this step, get ready to:
  • Deploy the Rust Worker API and apply D1 migrations to production.
  • Deploy the Next.js frontend with the production API URL baked in.
  • Test the full end-to-end flow in production.
Deploy the Rust Worker API to production

The Rust API needs to be live before the frontend can talk to it. You will deploy the Worker and then apply your D1 database migrations to the production database.

  • Switch into the blog-api folder in your terminal by running:
cd blog-api
  • Deploy the Rust Worker to Cloudflare by running:
npx wrangler deploy

What does this command do?

Wrangler compiles your Rust code to WebAssembly (using the build command in wrangler.toml), then uploads the resulting Worker to Cloudflare's network.

After a successful deploy, Wrangler prints the live URL. It follows the format https://blog-api.<your-subdomain>.workers.dev. Copy this URL. You will need it in the next substep.

  • Note the URL that Wrangler prints after a successful deploy. It will look something like https://blog-api.<your-subdomain>.workers.dev.
  • Apply your D1 database migrations to the production database by running:
npx wrangler d1 migrations apply blog-db --remote

What does this command do?

The --remote flag tells Wrangler to run the migration against the live D1 database on Cloudflare instead of the local SQLite copy you used during development.

This creates the posts table in production so your API can read and write data.

  • Verify the API is live by running this curl command with your deployed URL:
curl [[RUST_API_URL="https://blog-api.your-subdomain.workers.dev"]]/posts
  • You should see an empty JSON array [] in your terminal, confirming the API is responding.

Seeing a 523 error?

If you see a 523 error when hitting your *.workers.dev subdomain for the first time, wait about a minute and try again. The subdomain takes a moment to propagate.

If you see a different error, check that your wrangler.toml has the correct database_id and that you ran the migrations with --remote.

Help me debug my Rust Worker deployment

Deploy the Next.js frontend to Cloudflare

The frontend uses NEXT_PUBLIC_API_URL to know where the Rust API lives. Since NEXT_PUBLIC_ variables are baked into the bundle at build time, you need to set the production URL before building.

  • Switch into the blog-frontend folder by running:
cd ../blog-frontend
  • Build the Next.js frontend with the production API URL by running:
NEXT_PUBLIC_API_URL=[[RUST_API_URL="https://blog-api.your-subdomain.workers.dev"]] npx opennextjs-cloudflare build

Why set the variable inline?

Prefixing the build command with NEXT_PUBLIC_API_URL=... sets the environment variable for that single command. Next.js reads it at build time and inlines it into the JavaScript bundle.

Your .env.local stays unchanged for local development.

Windows users

On Windows (PowerShell), use a separate $env: assignment before the build command:

$env:NEXT_PUBLIC_API_URL="https://blog-api.your-subdomain.workers.dev"
npx opennextjs-cloudflare build
  • Deploy the built frontend to Cloudflare by running:
npx opennextjs-cloudflare deploy
  • Wrangler prints the deployed frontend URL after a successful deploy. It will look like https://blog-frontend.<your-subdomain>.workers.dev.
  • Open that URL in your browser. You should see your blog's home page with the heading My Blog and a Create New Post link.

Seeing a blank page or build error?

If the build fails, check that you typed the full NEXT_PUBLIC_API_URL including https://. A missing protocol causes fetch failures at runtime.

If the page loads but shows no posts, that is expected. You have not created any posts in production yet.

Help me debug my Next.js Cloudflare deployment

Test the full flow end-to-end

Both services are live. Time to verify the entire pipeline works: creating a post with media on the frontend, then confirming it shows up in the blog listing.

  • Open your deployed frontend URL in your browser.
  • Click the Create New Post link.
  • Enter a title like Hello from the Edge in the title field.
  • Enter some content in the content field.
  • Upload an image using the Upload Image button. Wait for the preview to appear.
  • Click Publish.
  • The page redirects to the home page. You should see your new post listed with its title and date.
  • Click on the post title to view the full post detail page, including your uploaded image.

What just happened?

Your browser sent the post data (including the Cloudinary image URL) to the Rust Worker API on Cloudflare. The Worker inserted a row into the production D1 database. When the page redirected, the Next.js Worker fetched the posts list from the Rust API using server-side rendering and returned the fully-rendered HTML.

All of this happened on Cloudflare's global edge network. No origin server, no cold VMs. Just WebAssembly and V8 isolates.

Post not appearing after publishing?

Open your browser's DevTools network tab and check the request to /posts. If you see a CORS error, your Rust API may need CORS headers for cross-origin requests from the frontend Worker.

If the POST succeeds but the listing is empty, confirm the frontend's NEXT_PUBLIC_API_URL matches the exact URL from your Rust Worker deploy (including https://).

Help me debug my production blog flow

Secret mission

Add Markdown Rendering with Syntax Highlighting

Your blog posts are currently plain text. Real developer blogs render Markdown with syntax-highlighted code blocks. Add a Markdown processor to your post detail page so your content looks professional.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on Cloudflare's free tier and Cloudinary's free tier, so there are no ongoing costs while your resources stay live. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Cloudflare Worker (Rust API) named blog-api on the Workers Free tier (100,000 requests/day shared across all Workers).
  • Cloudflare Worker (Next.js frontend) named blog-frontend on the same shared daily limit.
  • Cloudflare D1 database named blog-db on the Free tier (5 million reads/day, 100,000 writes/day, 5 GB storage).
  • Cloudinary uploads on the Free tier (25 credits/month).
  • Local project folders blog-api and blog-frontend on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your blog live as a portfolio piece.

  • Both Workers stay accessible on their *.workers.dev subdomains at $0/month.
  • Your D1 database retains all posts within the Free tier limits.
  • Your Cloudinary uploads remain available within your monthly credits.
  • Your local blog-api and blog-frontend folders remain on your Desktop for future development.

Pause - I'll come back to this later

Free up local disk space while keeping your deployed services live. Since Cloudflare Workers are serverless and cost nothing on the Free tier, there is nothing to pause on the cloud side.

  • Your deployed Workers and D1 database stay live at no cost. No cloud-side action needed.
  • Optionally delete local project folders to free disk space. You can re-clone or recreate them later.

Delete - I don't want to use this again

Remove all project resources completely. This is irreversible for your deployed Workers and database.

  • Delete the Rust API Worker by running this command from inside your blog-api folder:
npx wrangler delete
  • Delete the Next.js frontend Worker by running this command from inside your blog-frontend folder:
npx wrangler delete
  • Delete the D1 database by running:
npx wrangler d1 delete blog-db
  • Delete your local project folders by running:
cd ~/Desktop
rm -rf blog-api blog-frontend
  • To remove your Cloudinary uploads, log in to your Cloudinary Console.
  • Open the Media Library.
  • Select all uploaded assets from this project, then click Delete.

That's a Wrap!

That's a Wrap!

You did it! You've built and deployed a full-stack blog platform on the edge.

You've learned how to:

  • Built a Rust REST API compiled to WebAssembly and deployed it on Cloudflare Workers with a D1 SQLite database for blog post CRUD operations.
  • Deployed a Next.js 15 frontend with server-side rendering via the OpenNext Cloudflare adapter to the global edge network.
  • Integrated Cloudinary for image and video uploads directly from the browser using unsigned upload presets.
  • Added markdown rendering with syntax highlighting for rich blog content.

Ready to quiz yourself?