Introduction

Welcome to your first major project on Void Cloud! So far, we’ve explored the foundational concepts, set up our development environment, and deployed simple static sites and serverless functions. Now, it’s time to bring everything together and build a complete full-stack web application.

In this chapter, you’ll learn how to combine a modern frontend framework (Next.js) with Void Cloud’s powerful serverless functions to create an interactive web application. We’ll build a simple “Note-Taking App” that allows users to create, view, and delete notes. This project will solidify your understanding of how different components integrate within the Void Cloud ecosystem, from local development to seamless cloud deployment. Get ready to put your knowledge into practice and see your full-stack vision come to life!

To get the most out of this chapter, you should be familiar with:

  • Basic JavaScript/TypeScript and React concepts.
  • Working with the Void Cloud CLI (voidctl).
  • Deploying static sites and serverless functions as covered in previous chapters.

Core Concepts

Building a full-stack application involves several moving parts. Let’s briefly recap the core concepts we’ll be leveraging:

The Full-Stack Architecture on Void Cloud

A typical full-stack application on Void Cloud often involves:

  1. Frontend: Your user interface, built with a framework like Next.js, React, Vue, or Svelte. This will be served as a static asset from Void Cloud’s global edge network.
  2. Backend: Your API logic, database interactions, and any server-side computations. On Void Cloud, this is often implemented using Serverless Functions (also known as “Void Functions” or “Edge Functions”). These functions run on demand, scale automatically, and are often deployed globally for low latency.
  3. Data Persistence: A database to store your application’s data. For simplicity in this initial project, we’ll start with an in-memory data store for the backend, but we’ll discuss how to integrate with external databases like PostgreSQL or MongoDB later.

Let’s visualize this architecture:

flowchart TD User(User Browser) -->|HTTP Request| Void_Edge["Void Cloud Edge Network"] Void_Edge -->|Serve Static Assets| Frontend_App["Frontend App "] Frontend_App -->|API Call| Void_Edge Void_Edge -->|Route to Function| Void_Function["Void Function "] Void_Function --> Data_Store["Data Store "] Data_Store --> Void_Function Void_Function --> Frontend_App Frontend_App --> User
  • User Browser: This is where your users interact with your application.
  • Void Cloud Edge Network: Void Cloud automatically routes requests to the closest edge location, serving your static frontend files rapidly.
  • Frontend App (Next.js): Your client-side application, built and optimized for fast loading and interactivity.
  • API Call: When the frontend needs data or to perform server-side actions, it makes an API call.
  • Void Function (Backend API): A serverless function that handles API requests, performs business logic, and interacts with the database. These scale automatically and only run when invoked.
  • Data Store: Where your application’s persistent data lives. For this chapter, we’ll initially simulate this, but in a real-world scenario, you’d connect to a managed database service.

Next.js for the Frontend

Next.js is a React framework that enables powerful features like server-side rendering (SSR), static site generation (SSG), and API routes out of the box. For our project, it’s an excellent choice because:

  • Integrated Development Experience: It handles bundling, compilation, and offers a great developer experience.
  • Performance: Optimized for speed, delivering highly performant user interfaces.
  • Easy Deployment: Void Cloud has first-class support for Next.js, detecting its configuration and deploying it efficiently.

Void Cloud Serverless Functions for the Backend

Void Cloud’s serverless functions are the backbone of our backend API. They offer several advantages:

  • Automatic Scaling: Your functions scale from zero to thousands of requests without any manual intervention.
  • Pay-per-use: You only pay for the compute time your functions actually consume.
  • Global Deployment: Functions can be deployed to multiple regions, reducing latency for your users worldwide.
  • Simplified Operations: Void Cloud handles the underlying infrastructure, patching, and scaling, letting you focus on your code.

In this project, we’ll define our API endpoints as individual Void Functions, each responsible for a specific operation (e.g., getting all notes, creating a note, deleting a note).

Step-by-Step Implementation: Building Our Note-Taking App

Let’s start building our full-stack note-taking application. We’ll keep it simple: create notes with a title and content, view all notes, and delete notes.

Step 1: Initialize Your Project Structure

We’ll create a monorepo-like structure where our frontend and backend code live in separate directories within a single Void Cloud project. This makes managing related services easier.

First, create a new directory for our project and navigate into it:

mkdir void-notes-app
cd void-notes-app

Now, let’s initialize the frontend and backend directories.

Step 2: Set Up the Frontend (Next.js)

We’ll use create-next-app to scaffold our Next.js frontend.

  1. Create Next.js App: Run the following command in your void-notes-app directory:

    npx create-next-app@latest frontend --typescript --eslint --tailwind --app --src-dir --use-pnpm
    
    • @latest: Ensures we use the most current stable version of create-next-app (as of 2026-03-14, this would be a recent stable release like Next.js v15.x or v16.x).
    • frontend: This is the directory name for our frontend application.
    • --typescript: To use TypeScript, a modern best practice.
    • --eslint: For code quality.
    • --tailwind: For styling (optional, but good for quick UI).
    • --app: Uses the new App Router, which is the recommended approach for Next.js.
    • --src-dir: Organizes code in a src directory.
    • --use-pnpm: Uses pnpm for faster and more efficient package management (you can use npm or yarn if preferred by omitting this flag).

    Follow the prompts. For most, you can accept the defaults.

  2. Navigate and Test Frontend: Change into the frontend directory and run the development server to ensure everything is set up correctly:

    cd frontend
    pnpm dev # or npm run dev / yarn dev
    

    Open http://localhost:3000 in your browser. You should see the default Next.js welcome page.

    Press Ctrl+C to stop the development server.

  3. Install swr for Data Fetching: swr (Stale-While-Revalidate) is a great React hook library for data fetching, caching, and revalidation. It simplifies interacting with our API.

    pnpm add swr # or npm install swr / yarn add swr
    

Step 3: Set Up the Backend (Void Cloud Serverless Functions)

Now, let’s create our backend functions. We’ll store them in a backend directory.

  1. Create Backend Directory: Navigate back to the root of your project (void-notes-app) and create a backend directory:

    cd .. # Go back to void-notes-app
    mkdir backend
    cd backend
    
  2. Initialize Node.js Project: Initialize a new Node.js project with TypeScript.

    pnpm init # or npm init -y / yarn init -y
    pnpm add typescript @types/node ts-node # or npm install typescript @types/node ts-node
    pnpm add -D @void/functions-types # This is a hypothetical Void Cloud SDK type definition
    
    • @void/functions-types: We’re assuming Void Cloud provides specific types for its function environment, similar to AWS Lambda or Vercel Functions.
  3. Configure TypeScript: Create a tsconfig.json file in the backend directory with basic configuration for serverless functions.

    // backend/tsconfig.json
    {
      "compilerOptions": {
        "target": "ES2022", // Modern ECMAScript target
        "module": "CommonJS", // Or ESNext if your Void functions support ESM directly
        "lib": ["ES2022"],
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "outDir": "./dist", // Output compiled JavaScript to 'dist'
        "rootDir": "./src" // Source files are in 'src'
      },
      "include": ["src/**/*.ts"],
      "exclude": ["node_modules"]
    }
    

    Explanation: This configuration tells the TypeScript compiler how to process our .ts files. We’re targeting a modern JavaScript version, using CommonJS modules (common for serverless runtimes), and enabling strict type checking.

  4. Create a src directory:

    mkdir src
    
  5. Create Our First Void Function (Get All Notes): Void Cloud functions are typically defined as individual files in a specific directory (e.g., src/api). Each file exports a handler function.

    Create backend/src/api/notes/index.ts:

    mkdir -p src/api/notes
    touch src/api/notes/index.ts
    

    Now, open backend/src/api/notes/index.ts and add the following code:

    // backend/src/api/notes/index.ts
    import { VoidFunctionRequest, VoidFunctionResponse } from '@void/functions-types';
    
    // In-memory "database" for demonstration purposes
    interface Note {
      id: string;
      title: string;
      content: string;
      createdAt: string;
    }
    
    const notes: Note[] = []; // Our initial empty array of notes
    
    // Helper to generate unique IDs
    const generateId = () => Math.random().toString(36).substring(2, 9);
    
    export default async function handler(
      req: VoidFunctionRequest,
      res: VoidFunctionResponse
    ) {
      // Set CORS headers for local development and frontend access
      res.setHeader('Access-Control-Allow-Origin', '*'); // Allow all origins for now
      res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
      res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
    
      // Handle preflight OPTIONS request
      if (req.method === 'OPTIONS') {
        return res.status(200).send('');
      }
    
      if (req.method === 'GET') {
        // Get all notes
        return res.status(200).json(notes);
      } else if (req.method === 'POST') {
        // Create a new note
        const { title, content } = req.body;
        if (!title || !content) {
          return res.status(400).json({ error: 'Title and content are required.' });
        }
        const newNote: Note = {
          id: generateId(),
          title,
          content,
          createdAt: new Date().toISOString(),
        };
        notes.push(newNote);
        return res.status(201).json(newNote);
      } else if (req.method === 'DELETE') {
        // Delete a note by ID
        const { id } = req.query; // Assuming ID comes as a query parameter for DELETE
        const initialLength = notes.length;
        const noteIndex = notes.findIndex(note => note.id === id);
    
        if (noteIndex === -1) {
          return res.status(404).json({ error: 'Note not found.' });
        }
    
        notes.splice(noteIndex, 1); // Remove the note
        return res.status(204).send(''); // 204 No Content for successful deletion
      }
    
      // If method is not handled
      return res.status(405).json({ error: 'Method Not Allowed' });
    }
    

    Explanation:

    • We import VoidFunctionRequest and VoidFunctionResponse types to ensure type safety for our handler.
    • notes is a simple in-memory array acting as our temporary database. Important: In a real serverless environment, this array would reset with each function invocation, meaning data isn’t persistent. We’ll address this by discussing real databases later. For local testing, it will persist as long as the ts-node process runs.
    • The handler function checks req.method to determine if it’s a GET, POST, or DELETE request.
    • CORS Headers: These are crucial for local development, allowing our frontend (running on localhost:3000) to make requests to our backend (which will run on a different port/domain). For production, you’d restrict Access-Control-Allow-Origin to your frontend’s domain.
    • GET /api/notes: Returns all notes.
    • POST /api/notes: Creates a new note from the request body.
    • DELETE /api/notes?id=...: Deletes a note by its ID.
    • OPTIONS: Handles CORS preflight requests.
  6. Test Backend Locally: We can use ts-node to run our function locally as a simple HTTP server. This isn’t how Void Cloud runs it, but it’s great for quick local testing.

    First, install express and body-parser to simulate a basic HTTP server for local testing:

    pnpm add express body-parser # or npm install express body-parser
    pnpm add -D @types/express @types/body-parser # or npm install -D @types/express @types/body-parser
    

    Create a file backend/dev-server.ts to simulate the Void Cloud function environment:

    // backend/dev-server.ts
    import express from 'express';
    import bodyParser from 'body-parser';
    import handler from './src/api/notes'; // Our Void Function handler
    
    const app = express();
    const port = 4000; // Choose a different port than frontend
    
    app.use(bodyParser.json());
    
    // Simulate Void Cloud's routing for /api/notes
    app.all('/api/notes', async (req, res) => {
      // Simulate VoidFunctionRequest and VoidFunctionResponse structure
      const voidReq: any = {
        method: req.method,
        headers: req.headers,
        body: req.body,
        query: req.query,
        url: req.url,
      };
      const voidRes: any = {
        status: (statusCode: number) => {
          res.status(statusCode);
          return voidRes; // Chainable
        },
        json: (data: any) => {
          res.json(data);
          return voidRes;
        },
        send: (data: string | undefined) => {
          res.send(data);
          return voidRes;
        },
        setHeader: (name: string, value: string) => {
          res.setHeader(name, value);
          return voidRes;
        }
      };
      await handler(voidReq, voidRes);
    });
    
    app.listen(port, () => {
      console.log(`Backend dev server running on http://localhost:${port}`);
      console.log('Test endpoints: GET /api/notes, POST /api/notes, DELETE /api/notes?id=...');
    });
    

    Explanation: This dev-server.ts file sets up a simple Express server. It intercepts all requests to /api/notes and passes them to our handler function, simulating how Void Cloud would invoke it.

    Now, run the backend dev server:

    pnpm ts-node dev-server.ts # or npx ts-node dev-server.ts
    

    You should see “Backend dev server running on http://localhost:4000”.

    Test it using curl or Postman/Insomnia:

    • GET http://localhost:4000/api/notes (should return [])
    • POST http://localhost:4000/api/notes -H "Content-Type: application/json" -d '{"title": "My First Note", "content": "This is the content."}'
    • GET http://localhost:4000/api/notes (should now show your note)
    • DELETE http://localhost:4000/api/notes?id=<ID_OF_YOUR_NOTE> (replace <ID_OF_YOUR_NOTE> with an actual note ID)

    Keep this backend server running in a separate terminal tab.

Step 4: Integrate Frontend with Backend

Now that our backend API is ready, let’s connect our Next.js frontend to it.

  1. Define API Endpoint: Go back to your frontend directory. We’ll define a constant for our backend API URL. This is important because it will be different in development vs. production.

    Create frontend/src/lib/constants.ts:

    cd ../frontend
    mkdir -p src/lib
    touch src/lib/constants.ts
    
    // frontend/src/lib/constants.ts
    export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:4000/api';
    

    Explanation:

    • process.env.NEXT_PUBLIC_API_BASE_URL: This is how Next.js handles environment variables that need to be exposed to the browser. In production, Void Cloud will inject this.
    • || 'http://localhost:4000/api': In local development, we’ll default to our local backend server.
  2. Create Note Component: We’ll create a simple component to display a single note and handle its deletion.

    Create frontend/src/components/NoteCard.tsx:

    // frontend/src/components/NoteCard.tsx
    interface Note {
      id: string;
      title: string;
      content: string;
      createdAt: string;
    }
    
    interface NoteCardProps {
      note: Note;
      onDelete: (id: string) => void;
    }
    
    export default function NoteCard({ note, onDelete }: NoteCardProps) {
      return (
        <div className="border p-4 rounded-md shadow-sm bg-white mb-4">
          <h3 className="text-xl font-semibold mb-2">{note.title}</h3>
          <p className="text-gray-700 mb-4">{note.content}</p>
          <p className="text-sm text-gray-500">Created: {new Date(note.createdAt).toLocaleString()}</p>
          <button
            onClick={() => onDelete(note.id)}
            className="mt-4 px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors"
          >
            Delete
          </button>
        </div>
      );
    }
    
  3. Update Main Page to Fetch and Display Notes: Now, let’s modify frontend/src/app/page.tsx to fetch notes from our backend and display them. We’ll also add a form to create new notes.

    // frontend/src/app/page.tsx
    'use client'; // This directive makes it a Client Component
    
    import { useState } from 'react';
    import useSWR from 'swr';
    import NoteCard from '../components/NoteCard';
    import { API_BASE_URL } from '../lib/constants';
    
    interface Note {
      id: string;
      title: string;
      content: string;
      createdAt: string;
    }
    
    // A simple fetcher function for SWR
    const fetcher = (url: string) => fetch(url).then(res => res.json());
    
    export default function Home() {
      const { data: notes, error, isLoading, mutate } = useSWR<Note[]>(`${API_BASE_URL}/notes`, fetcher);
    
      const [newNoteTitle, setNewNoteTitle] = useState('');
      const [newNoteContent, setNewNoteContent] = useState('');
    
      const handleCreateNote = async (e: React.FormEvent) => {
        e.preventDefault();
        if (!newNoteTitle || !newNoteContent) return;
    
        try {
          await fetch(`${API_BASE_URL}/notes`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ title: newNoteTitle, content: newNoteContent }),
          });
          setNewNoteTitle('');
          setNewNoteContent('');
          mutate(); // Revalidate SWR cache to fetch updated notes
        } catch (err) {
          console.error('Failed to create note:', err);
        }
      };
    
      const handleDeleteNote = async (id: string) => {
        try {
          await fetch(`${API_BASE_URL}/notes?id=${id}`, {
            method: 'DELETE',
          });
          mutate(); // Revalidate SWR cache
        } catch (err) {
          console.error('Failed to delete note:', err);
        }
      };
    
      if (error) return <div className="text-red-500">Failed to load notes: {error.message}</div>;
      if (isLoading) return <div className="text-blue-500">Loading notes...</div>;
    
      return (
        <div className="container mx-auto p-4 max-w-2xl">
          <h1 className="text-4xl font-bold mb-8 text-center text-gray-800">Void Notes App</h1>
    
          <form onSubmit={handleCreateNote} className="bg-gray-100 p-6 rounded-lg shadow-md mb-8">
            <h2 className="text-2xl font-semibold mb-4">Create New Note</h2>
            <div className="mb-4">
              <label htmlFor="title" className="block text-gray-700 text-sm font-bold mb-2">Title</label>
              <input
                type="text"
                id="title"
                value={newNoteTitle}
                onChange={(e) => setNewNoteTitle(e.target.value)}
                className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
                placeholder="Note title"
                required
              />
            </div>
            <div className="mb-6">
              <label htmlFor="content" className="block text-gray-700 text-sm font-bold mb-2">Content</label>
              <textarea
                id="content"
                value={newNoteContent}
                onChange={(e) => setNewNoteContent(e.target.value)}
                className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline h-24 resize-none"
                placeholder="Write your note here..."
                required
              />
            </div>
            <button
              type="submit"
              className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
            >
              Add Note
            </button>
          </form>
    
          <h2 className="text-3xl font-bold mb-6 text-gray-800">Your Notes</h2>
          <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
            {notes && notes.length > 0 ? (
              notes.map((note) => (
                <NoteCard key={note.id} note={note} onDelete={handleDeleteNote} />
              ))
            ) : (
              <p className="text-gray-600 col-span-full text-center">No notes yet. Start by creating one above!</p>
            )}
          </div>
        </div>
      );
    }
    

    Explanation:

    • 'use client';: This directive is crucial for Next.js App Router to indicate that this component runs on the client side and can use React hooks like useState and useSWR.
    • useSWR: This hook handles fetching notes from our API_BASE_URL/notes endpoint. It also provides mutate to easily re-fetch data after creating or deleting a note.
    • handleCreateNote and handleDeleteNote: These functions make POST and DELETE requests to our backend API, respectively, and then call mutate() to update the UI with the latest data.
    • The UI includes a form for adding notes and a list that maps notes data to NoteCard components.
  4. Run Both Frontend and Backend Locally: Make sure your backend/dev-server.ts is running in one terminal:

    cd void-notes-app/backend
    pnpm ts-node dev-server.ts
    

    In a separate terminal, start the Next.js frontend:

    cd void-notes-app/frontend
    pnpm dev
    

    Now, open http://localhost:3000. You should be able to create and delete notes, with the data appearing and disappearing as you interact with the app. Remember that the backend’s in-memory storage resets if you restart dev-server.ts.

Step 5: Deploy to Void Cloud

Now for the exciting part: deploying our full-stack application to Void Cloud!

  1. Configure Void Cloud Project: Void Cloud uses a configuration file (typically void.json or void.yaml) at the root of your project to understand how to deploy different parts of your application.

    Navigate back to the root of your project (void-notes-app):

    cd .. # From frontend to void-notes-app
    

    Create a void.json file:

    // void-notes-app/void.json
    {
      "version": 3,
      "name": "void-notes-app",
      "builds": [
        {
          "src": "frontend/package.json",
          "use": "@void/next-builder"
        },
        {
          "src": "backend/src/api/**/*.ts",
          "use": "@void/node-serverless-builder",
          "config": {
            "includeFiles": ["backend/package.json", "backend/pnpm-lock.yaml", "backend/tsconfig.json"]
          }
        }
      ],
      "routes": [
        {
          "src": "/api/(.*)",
          "dest": "backend/src/api/$1"
        },
        {
          "src": "/(.*)",
          "dest": "frontend/$1"
        }
      ],
      "env": {
        "NEXT_PUBLIC_API_BASE_URL": "https://void-notes-app-YOURPROJECTID.void.app/api"
      }
    }
    

    Explanation:

    • "version": 3: Specifies the Void Cloud configuration format version.
    • "name": "void-notes-app": The name of your project on Void Cloud.
    • "builds": This array tells Void Cloud how to build different parts of your application.
      • The first entry specifies that anything related to frontend/package.json should use the @void/next-builder. Void Cloud will automatically detect and build your Next.js application.
      • The second entry tells Void Cloud to treat all .ts files under backend/src/api as serverless functions, using the @void/node-serverless-builder.
      • "config": { "includeFiles": [...] }: This is crucial! Since our backend directory is not the root of the project, we need to explicitly tell the builder to include necessary files like package.json (for dependencies), pnpm-lock.yaml (for exact dependency versions), and tsconfig.json (for TypeScript compilation) from the backend directory.
    • "routes": This defines how incoming requests are routed to your builds.
      • "src": "/api/(.*)", "dest": "backend/src/api/$1": Any request starting with /api/ will be routed to our backend serverless functions. The (.*) captures the rest of the path and $1 passes it to the function. So /api/notes routes to backend/src/api/notes/index.ts.
      • "src": "/(.*)", "dest": "frontend/$1": All other requests (that don’t match /api/) are routed to the frontend application.
    • "env": This section defines environment variables.
      • "NEXT_PUBLIC_API_BASE_URL": This is the production URL for our backend API. You’ll need to replace YOURPROJECTID with the actual project ID or deployment URL Void Cloud gives you after the first deployment. For now, use a placeholder. Void Cloud automatically provides a unique subdomain for each project.
      • Important Note on NEXT_PUBLIC_API_BASE_URL: During the first deployment, you won’t know the exact URL. A common practice is to deploy once, get the URL, then update void.json and redeploy. For simplicity, we’ll assume a pattern: https://[PROJECT_NAME]-[RANDOM_ID].void.app/api. Void Cloud’s CLI usually gives you the deployment URL after a successful deployment. We’ll simulate this.
  2. Login to Void Cloud CLI: If you haven’t already, log in to your Void Cloud account using the CLI:

    voidctl login
    

    This will open your browser for authentication.

  3. Deploy the Project: From the root of your void-notes-app directory, initiate the deployment:

    voidctl deploy
    

    The CLI will guide you through the initial setup if this is your first time deploying this project:

    • It might ask “Set up and deploy void-notes-app to Void Cloud?” (Y/n) -> Type Y.
    • “Which scope (organization/personal account) do you want to deploy to?” -> Select your personal account or team.
    • “Link to existing project?” (Y/n) -> Type n (this is a new project).
    • “What’s your project’s name?” -> Default void-notes-app (press Enter).
    • “In which directory is your code located?” -> Default . (press Enter, as void.json is at the root).

    Void Cloud will then analyze your void.json, build your frontend and backend functions, and deploy them. This process might take a few minutes.

    Once complete, you’ll see a production URL like https://void-notes-app-xxxxxx.void.app (where xxxxxx is a unique ID). CRITICAL: Copy this URL!

  4. Update NEXT_PUBLIC_API_BASE_URL and Redeploy: Now that you have the production URL, update void.json with the correct NEXT_PUBLIC_API_BASE_URL. For example, if your deployment URL was https://void-notes-app-abc12345.void.app, then your API_BASE_URL would be https://void-notes-app-abc12345.void.app/api.

    // void-notes-app/void.json (updated)
    {
      "version": 3,
      "name": "void-notes-app",
      "builds": [
        {
          "src": "frontend/package.json",
          "use": "@void/next-builder"
        },
        {
          "src": "backend/src/api/**/*.ts",
          "use": "@void/node-serverless-builder",
          "config": {
            "includeFiles": ["backend/package.json", "backend/pnpm-lock.yaml", "backend/tsconfig.json"]
          }
        }
      ],
      "routes": [
        {
          "src": "/api/(.*)",
          "dest": "backend/src/api/$1"
        },
        {
          "src": "/(.*)",
          "dest": "frontend/$1"
        }
      ],
      "env": {
        "NEXT_PUBLIC_API_BASE_URL": "https://void-notes-app-abc12345.void.app/api" // <-- UPDATE THIS!
      }
    }
    

    Save the file, and then redeploy:

    voidctl deploy
    

    This second deployment will be faster as Void Cloud caches builds. Once complete, open the new production URL in your browser. You should now have a fully functional note-taking app deployed on Void Cloud!

    Remember: Since our backend uses in-memory storage, notes will not persist across function invocations or deployments. We’ll tackle persistent storage in a later chapter.

Mini-Challenge: Add a “Clear All Notes” Button

Let’s enhance our note-taking app with a new feature.

Challenge: Add a button to the frontend that, when clicked, sends a DELETE request to a new Void Cloud backend function /api/notes/clear to remove all notes.

Hints:

  1. Backend: Create a new file backend/src/api/notes/clear.ts. This function should export a handler that, when invoked with a DELETE request, empties the notes array. Remember that the notes array is scoped to index.ts, so you’ll need to refactor it into a separate module (e.g., backend/src/lib/data.ts) and import it into both index.ts and clear.ts.
  2. Frontend: Add a new button to frontend/src/app/page.tsx and implement an onClick handler that sends a DELETE request to API_BASE_URL/notes/clear. Don’t forget to mutate() the SWR cache after the operation.
  3. Deployment: You shouldn’t need to change void.json as the backend/src/api/**/*.ts glob pattern will automatically pick up your new function. Just run voidctl deploy again after making changes.

What to Observe/Learn:

  • How to extend your backend with new serverless functions.
  • How to refactor shared logic (like our in-memory data store) between functions.
  • The ease of adding new API endpoints and connecting them to the frontend.
  • Void Cloud’s automatic detection and deployment of new functions.

Common Pitfalls & Troubleshooting

  1. CORS Issues (Cross-Origin Resource Sharing):

    • Symptom: Your frontend makes requests to the backend, but you see errors like “Access to fetch has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present…”
    • Cause: The browser prevents a web page from making requests to a different domain (or port) than the one it originated from unless the server explicitly allows it via CORS headers.
    • Solution: Ensure your backend functions (backend/src/api/notes/index.ts) set Access-Control-Allow-Origin: * during local development. For production, replace * with your actual frontend domain (e.g., https://void-notes-app-abc12345.void.app). Also, ensure Access-Control-Allow-Methods and Access-Control-Allow-Headers are correctly set, and OPTIONS requests are handled.
  2. In-Memory Data Not Persisting in Production:

    • Symptom: You create notes, but after refreshing the page or waiting a bit, they disappear.
    • Cause: Void Cloud serverless functions are ephemeral. Each invocation might run on a fresh instance, and any in-memory state (like our notes array) is lost between invocations.
    • Solution: This is expected for an in-memory store. For persistent data, you must integrate with an external database service (e.g., PostgreSQL, MongoDB, Redis, or a Void Cloud Database offering if available). We will cover this in detail in a future chapter.
  3. Incorrect NEXT_PUBLIC_API_BASE_URL:

    • Symptom: Frontend fails to fetch data from the backend after deployment, but works locally. Network requests show 404s or connection errors to the backend API.
    • Cause: The NEXT_PUBLIC_API_BASE_URL environment variable in your void.json (or the local default) points to the wrong URL.
    • Solution: Double-check the deployment URL provided by voidctl deploy and ensure it’s correctly updated in void.json under the env section. Remember to redeploy after updating void.json.
  4. Backend Dependencies Not Found:

    • Symptom: Deployment fails or backend function logs show errors about missing modules (e.g., “Cannot find module ’express’”).
    • Cause: The @void/node-serverless-builder didn’t correctly pick up your backend/package.json to install dependencies.
    • Solution: Verify the config.includeFiles array in your void.json for the backend build step. It must explicitly list backend/package.json and any lock files (pnpm-lock.yaml, package-lock.json, yarn.lock).

Summary

Congratulations! You’ve just built and deployed your first full-stack web application on Void Cloud. This is a significant milestone!

Here’s what we covered in this chapter:

  • Full-Stack Architecture: Understanding how frontend (Next.js), backend (Void Cloud Serverless Functions), and data persistence work together.
  • Frontend Development: Setting up a Next.js application, fetching data with useSWR, and interacting with a backend API.
  • Backend Development: Creating Void Cloud serverless functions with TypeScript, handling different HTTP methods (GET, POST, DELETE), and managing a simple in-memory data store.
  • Local Development Workflow: Running both frontend and backend locally for rapid iteration.
  • Void Cloud Deployment: Configuring void.json to deploy a multi-service application (static frontend + serverless backend) and using voidctl deploy.
  • Environment Variables: Managing API endpoints for different environments using NEXT_PUBLIC_API_BASE_URL.

While our note-taking app uses in-memory storage for simplicity, you now have a solid foundation. In upcoming chapters, we’ll dive into integrating real databases, implementing user authentication, and exploring more advanced Void Cloud features to build truly production-ready applications.

References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.