Upload Files to Google Cloud Storage with Next.js: A Complete Server Proxy Guide

Learn how to securely upload audio, images, and other files to Google Cloud Storage using a server-side proxy in a Next.js 15 application

·Matija Žiberna·
Upload Files to Google Cloud Storage with Next.js: A Complete Server Proxy Guide

When building modern web applications, file uploads are a common requirement. Whether you're creating a voice recording app, image gallery, or document management system, you'll often need to store files in cloud storage services like Google Cloud Storage (GCS).

In this comprehensive guide, we'll build a file upload system for a web application where users can record audio using the HTML5 Audio API and upload it directly to Google Cloud Storage. Instead of storing files on our server (which can be expensive and resource-intensive), we'll leverage GCS for scalable, reliable file storage.

We'll explore two different approaches for uploading files to GCS and implement the more robust server proxy method that eliminates CORS issues and provides better security.

Understanding Upload Approaches

Before diving into implementation, it's important to understand the different approaches for uploading files to Google Cloud Storage:

Approach A: Direct Client Upload

In this approach, the client (browser) uploads files directly to Google Cloud Storage using pre-signed URLs.

Benefits:

  • Faster uploads (no server bottleneck)
  • Reduces server bandwidth usage
  • Lower server resource consumption
  • Scales better for high-traffic applications

Downsides:

  • CORS configuration complexity
  • Security concerns (credentials visible to client)
  • Browser compatibility issues
  • Limited upload validation options
  • Harder to implement retry logic

Approach B: Server Proxy Upload (Our Choice)

In this approach, files are uploaded to your server first, then your server uploads them to Google Cloud Storage.

Benefits:

  • No CORS issues
  • Better security (credentials stay on server)
  • Server-side validation and processing
  • Reliable error handling and retry logic
  • Complete control over upload flow
  • Better monitoring and logging

Downsides:

  • Uses server bandwidth
  • Slightly higher latency
  • Server resource consumption
  • Potential bottleneck for concurrent uploads

For this tutorial, we'll implement Approach B (Server Proxy) because it provides the most reliable and secure solution for production applications.

Prerequisites

Before we start, make sure you have:

  • A Google Cloud Platform account
  • A Next.js project set up
  • Basic knowledge of React and API routes

Step 1: Setting Up Google Cloud Storage

In this step, we'll create a Google Cloud project, set up a storage bucket, and configure the necessary permissions for our application to upload files.

1.1 Create a Google Cloud Project

  1. Go to the Google Cloud Console
  2. Click "Select a project" and then "New Project"
  3. Give your project a name (e.g., "file-upload-demo")
  4. Click "Create"

1.2 Enable Cloud Storage API

  1. In your project dashboard, go to "APIs & Services" > "Library"
  2. Search for "Cloud Storage API"
  3. Click on it and press "Enable"

1.3 Create a Storage Bucket

  1. Navigate to "Cloud Storage" > "Buckets"
  2. Click "Create Bucket"
  3. Choose a globally unique bucket name (e.g., "my-app-uploads-bucket")
  4. Select a location (choose based on your users' geography)
  5. Click "Create"

1.4 Configure Bucket Access Control

This is a crucial step that many developers miss. By default, Google Cloud Storage uses uniform bucket-level access, but we need fine-grained access control.

  1. Go to your bucket's details page
  2. Click on the "Permissions" tab
  3. In the "Access control" section, click "Edit"
  4. Select "Fine-grained" instead of "Uniform"
  5. Check "Allow public access" (we'll configure this properly in the next steps)
  6. Click "Save"

bucket settings

Important Concept: Fine-grained access control allows us to set permissions at the object level, which is necessary for our upload mechanism to work properly.

1.5 Create a Service Account

A service account is what our application will use to authenticate with Google Cloud Storage.

  1. Go to "IAM & Admin" > "Service Accounts"
  2. Click "Create Service Account"
  3. Give it a name (e.g., "file-upload-service")
  4. Add description: "Service account for file uploads"
  5. Click "Create and Continue"
  6. In the "Grant this service account access to project" section, add the role "Storage Object Admin"
  7. Click "Continue" and then "Done"

Create new service account

1.6 Generate Service Account Key

  1. Click on your newly created service account
  2. Go to the "Keys" tab
  3. Click "Add Key" > "Create new key"
  4. Select "JSON" format
  5. Click "Create" (this will download a JSON file)

Security Note: Keep this JSON file secure and never commit it to version control.

1.7 Configure Service Account Permissions

Now we need to give our service account permission to create objects in our bucket:

  1. Go back to your bucket's "Permissions" tab
  2. Click "Grant Access"
  3. In "New principals", enter your service account email (found in the JSON file)
  4. In "Select a role", choose "Storage Legacy Bucket Owner and Storage Legacy Object Owner"
  5. Click "Save"

Important Concept: The "Storage Object Creator" role gives our service account the minimum necessary permissions to upload files to the bucket while maintaining security best practices.

Add role to service account

1.8 Convert Service Account Key to Base64

For easier environment variable management, we'll convert our JSON key to Base64:

  1. Go to a Base64 encoding website (like JSON to BASE64)
  2. Copy the entire contents of your downloaded JSON file
  3. Paste it and encode to Base64
  4. Copy the resulting Base64 string

Step 2: Setting Up the Next.js Environment

In this step, we'll configure our Next.js application with the necessary environment variables and dependencies for Google Cloud Storage integration.

2.1 Install Required Dependencies

npm install @google-cloud/storage uuid
npm install --save-dev @types/uuid

Package Explanation:

  • @google-cloud/storage: Official Google Cloud Storage client library
  • uuid: For generating unique file names
  • @types/uuid: TypeScript types for UUID

2.2 Configure Environment Variables

Create a .env.local file in your project root:

# Google Cloud Storage Configuration
GOOGLE_CLOUD_PROJECT_ID=your-project-id
GOOGLE_CLOUD_STORAGE_BUCKET=your-bucket-name
GOOGLE_CLOUD_CREDENTIALS_BASE64=your-base64-encoded-json-key

Security Tip: The Base64 approach makes it easier to deploy to platforms like Vercel or Netlify without dealing with file uploads for service account keys.

2.3 Create Google Cloud Storage Configuration

Create src/lib/google-storage.ts:

import { Storage } from '@google-cloud/storage';

// Decode the Base64 service account key
const credentials = JSON.parse(
  Buffer.from(process.env.GOOGLE_CLOUD_CREDENTIALS_BASE64!, 'base64').toString()
);

// Initialize Google Cloud Storage client
const storage = new Storage({
  projectId: process.env.GOOGLE_CLOUD_PROJECT_ID,
  credentials,
});

const bucket = storage.bucket(process.env.GOOGLE_CLOUD_STORAGE_BUCKET!);

export { storage, bucket };

Key Concept: We're initializing the Google Cloud Storage client with our decoded service account credentials. This client will be used on the server-side to generate pre-signed URLs and handle uploads.

Step 3: Creating Upload Credential Generation

In this step, we'll create a function that generates the necessary credentials for uploading files to Google Cloud Storage. This includes creating pre-signed URLs that allow our server to upload files on behalf of our application.

3.1 Implement Pre-signed URL Generation

Add this function to src/lib/google-storage.ts:

import { v4 as uuidv4 } from 'uuid';

export interface UploadCredentials {
  uploadUrl: string;
  gcsPath: string;
  gcsUri: string;
  bucketName: string;
  fileId: string;
}

/**
 * Generates pre-signed URL and metadata for file upload
 * @param userId - User identifier for organizing files
 * @param fileName - Optional custom filename
 * @returns Upload credentials object
 */
export const generateUploadCredentials = async (
  userId: string,
  fileName?: string
): Promise<UploadCredentials> => {
  try {
    console.log('[Generate Upload URLs] Starting credential generation for userId:', userId);

    // Generate unique file identifier
    const fileId = uuidv4();
    const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
    
    // Create organized file path: user_[userId]/[date]/[fileId].[extension]
    const fileExtension = fileName ? fileName.split('.').pop() : 'bin';
    const gcsPath = `user_${userId}/${today}/${fileId}.${fileExtension}`;
    
    // Create file reference in bucket
    const file = bucket.file(gcsPath);
    
    // Generate pre-signed URL for upload (valid for 15 minutes)
    const [uploadUrl] = await file.getSignedUrl({
      version: 'v4',
      action: 'write',
      expires: Date.now() + 15 * 60 * 1000, // 15 minutes
      contentType: 'application/octet-stream',
    });

    const credentials: UploadCredentials = {
      uploadUrl,
      gcsPath,
      gcsUri: `gs://${process.env.GOOGLE_CLOUD_STORAGE_BUCKET}/${gcsPath}`,
      bucketName: process.env.GOOGLE_CLOUD_STORAGE_BUCKET!,
      fileId,
    };

    console.log('[Generate Upload URLs] Generated credentials successfully');
    
    return credentials;
  } catch (error) {
    console.error('[Generate Upload URLs] Error generating credentials:', error);
    throw new Error('Failed to generate upload credentials');
  }
};

Important Concepts:

  • Pre-signed URLs: These are time-limited URLs that allow uploads without exposing your credentials
  • File Organization: We organize files by user and date for better management
  • URL Expiration: 15-minute expiration balances security with usability

Step 4: Building the Server Upload Logic

In this step, we'll create the server-side logic that handles file uploads. Our server will receive files from the client, then upload them to Google Cloud Storage using the pre-signed URLs we generate.

4.1 Create Server Upload Handler

Create src/lib/server-upload.ts:

import { generateUploadCredentials, UploadCredentials } from './google-storage';

export interface UploadResult {
  success: boolean;
  fileId?: string;
  gcsUri?: string;
  error?: string;
}

/**
 * Uploads a file to Google Cloud Storage from the server
 * @param fileBuffer - The file data as a Buffer
 * @param userId - User identifier
 * @param fileName - Original filename
 * @param contentType - MIME type of the file
 * @returns Upload result with success status and file information
 */
export const performServerUpload = async (
  fileBuffer: Buffer,
  userId: string,
  fileName: string,
  contentType: string
): Promise<UploadResult> => {
  try {
    console.log('[Server Upload] Starting upload process', {
      bufferSize: fileBuffer.length,
      fileName,
      contentType,
      userId
    });

    // Generate upload credentials
    const credentials = await generateUploadCredentials(userId, fileName);
    
    console.log('[Server Upload] Generated credentials successfully');

    // Upload file to Google Cloud Storage
    const uploadResult = await uploadToGCS(fileBuffer, credentials, contentType);
    
    console.log('[Server Upload] Upload completed successfully');

    return {
      success: true,
      fileId: credentials.fileId,
      gcsUri: credentials.gcsUri,
    };
  } catch (error: any) {
    console.error('[Server Upload] Upload failed:', error);
    return {
      success: false,
      error: error.message,
    };
  }
};

/**
 * Performs the actual upload to Google Cloud Storage
 * @param fileBuffer - File data
 * @param credentials - Upload credentials from generateUploadCredentials
 * @param contentType - MIME type
 */
const uploadToGCS = async (
  fileBuffer: Buffer,
  credentials: UploadCredentials,
  contentType: string
): Promise<void> => {
  console.log('[GCS Upload] Starting upload to Google Cloud Storage');

  try {
    const response = await fetch(credentials.uploadUrl, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/octet-stream',
        'Content-Length': fileBuffer.length.toString(),
      },
      body: fileBuffer,
    });

    if (!response.ok) {
      const errorText = await response.text();
      console.error('[GCS Upload] Upload failed:', {
        status: response.status,
        statusText: response.statusText,
        error: errorText
      });
      throw new Error(`GCS upload failed with status ${response.status}: ${errorText}`);
    }

    console.log('[GCS Upload] Upload successful');
  } catch (error: any) {
    console.error('[GCS Upload] Upload error:', error);
    throw new Error(`GCS upload failed: ${error.message}`);
  }
};

Key Concepts:

  • Buffer Handling: We work with Buffer objects for efficient file handling on the server
  • Error Handling: Comprehensive error handling with detailed logging for debugging
  • Content-Type: We use 'application/octet-stream' for maximum compatibility

Step 5: Creating the API Route

In this step, we'll create the Next.js API route that serves as the entry point for file uploads. This route will handle incoming requests from the client, validate files, and coordinate the upload process.

5.1 Create the Upload API Route

Create src/app/api/upload/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { performServerUpload } from '@/lib/server-upload';

// Configuration constants
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB limit
const ALLOWED_TYPES = [
  'audio/wav', 
  'audio/mpeg', 
  'audio/mp4', 
  'audio/webm',
  'image/jpeg',
  'image/png',
  'application/pdf'
];

// Simple rate limiting (in production, use Redis or a proper solution)
const uploadAttempts = new Map<string, { count: number; resetTime: number }>();
const MAX_UPLOADS_PER_HOUR = 50;
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour

/**
 * Basic rate limiting implementation
 * @param clientId - Client identifier (usually IP address)
 * @returns true if request is allowed, false if rate limited
 */
function checkRateLimit(clientId: string): boolean {
  const now = Date.now();
  const attempts = uploadAttempts.get(clientId);
  
  if (!attempts || now > attempts.resetTime) {
    uploadAttempts.set(clientId, { count: 1, resetTime: now + RATE_LIMIT_WINDOW });
    return true;
  }
  
  if (attempts.count >= MAX_UPLOADS_PER_HOUR) {
    return false;
  }
  
  attempts.count++;
  return true;
}

export async function POST(request: NextRequest) {
  try {
    console.log('[Upload API] Processing upload request');
    
    // Extract client IP for rate limiting
    const clientIp = request.headers.get('x-forwarded-for') || 
                     request.headers.get('x-real-ip') || 
                     'unknown';
    
    // Apply rate limiting
    if (!checkRateLimit(clientIp)) {
      return NextResponse.json(
        { error: 'Rate limit exceeded. Please try again later.' },
        { status: 429 }
      );
    }

    // Parse form data
    const formData = await request.formData();
    const file = formData.get('file') as File;
    const userId = formData.get('userId') as string;
    const fileName = formData.get('fileName') as string;

    // Validate required fields
    if (!file) {
      return NextResponse.json(
        { error: 'No file provided' },
        { status: 400 }
      );
    }

    if (!userId) {
      return NextResponse.json(
        { error: 'User ID is required' },
        { status: 400 }
      );
    }

    // Validate file size
    if (file.size > MAX_FILE_SIZE) {
      return NextResponse.json(
        { 
          error: `File size ${(file.size / 1024 / 1024).toFixed(1)}MB exceeds 20MB limit` 
        },
        { status: 400 }
      );
    }

    if (file.size === 0) {
      return NextResponse.json(
        { error: 'File is empty' },
        { status: 400 }
      );
    }

    // Validate file type
    if (file.type && !ALLOWED_TYPES.includes(file.type)) {
      return NextResponse.json(
        { 
          error: `File type ${file.type} not supported. Allowed types: ${ALLOWED_TYPES.join(', ')}` 
        },
        { status: 400 }
      );
    }

    console.log('[Upload API] File validation passed:', {
      fileName: file.name,
      fileSize: file.size,
      fileType: file.type,
      userId
    });

    // Generate unique filename if not provided
    const fileId = uuidv4();
    const fileExtension = fileName ? 
      fileName.split('.').pop() || 'bin' : 
      file.name ? file.name.split('.').pop() || 'bin' : 'bin';
    const uniqueFileName = `${fileId}.${fileExtension}`;

    // Convert file to buffer
    const fileBuffer = Buffer.from(await file.arrayBuffer());

    console.log('[Upload API] Starting server upload');

    // Perform upload
    const uploadResult = await performServerUpload(
      fileBuffer,
      userId,
      uniqueFileName,
      file.type || 'application/octet-stream'
    );

    if (!uploadResult.success) {
      return NextResponse.json(
        { 
          error: 'Upload failed', 
          details: uploadResult.error 
        },
        { status: 500 }
      );
    }

    // Return success response
    return NextResponse.json({
      success: true,
      fileId: uploadResult.fileId,
      fileName: uniqueFileName,
      gcsUri: uploadResult.gcsUri,
      message: 'File uploaded successfully'
    });

  } catch (error: any) {
    const errorId = uuidv4().slice(0, 8);
    console.error('[Upload API] Upload failed:', {
      error: error.message,
      errorId,
      stack: error.stack
    });

    return NextResponse.json(
      { 
        error: 'Upload failed', 
        details: error.message,
        errorId 
      },
      { status: 500 }
    );
  }
}

// Handle CORS preflight requests
export async function OPTIONS(request: NextRequest) {
  return new NextResponse(null, {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  });
}

Important Concepts:

  • Input Validation: We validate file size, type, and required fields before processing
  • Rate Limiting: Basic protection against abuse (use Redis in production)
  • Error Handling: Comprehensive error responses with unique error IDs for debugging
  • CORS Headers: Proper CORS handling for cross-origin requests

Step 6: Building the Client-Side Upload Component

In this step, we'll create a React component that allows users to select and upload files. This component will handle the user interface and communicate with our API route.

6.1 Create Upload Hook

First, let's create a custom hook for handling uploads. Create src/hooks/useFileUpload.ts:

import { useState } from 'react';

interface UploadResult {
  success: boolean;
  fileId?: string;
  fileName?: string;
  gcsUri?: string;
  message?: string;
  error?: string;
}

export const useFileUpload = () => {
  const [isUploading, setIsUploading] = useState(false);
  const [uploadProgress, setUploadProgress] = useState(0);
  const [uploadResult, setUploadResult] = useState<UploadResult | null>(null);

  const uploadFile = async (
    file: File,
    userId: string,
    fileName?: string
  ): Promise<UploadResult> => {
    setIsUploading(true);
    setUploadProgress(0);
    setUploadResult(null);

    try {
      // Create form data
      const formData = new FormData();
      formData.append('file', file);
      formData.append('userId', userId);
      if (fileName) {
        formData.append('fileName', fileName);
      }

      // Upload with progress tracking
      const result = await uploadWithProgress(formData);
      setUploadResult(result);
      return result;

    } catch (error: any) {
      const errorResult: UploadResult = {
        success: false,
        error: error.message
      };
      setUploadResult(errorResult);
      return errorResult;

    } finally {
      setIsUploading(false);
      setUploadProgress(100);
    }
  };

  const uploadWithProgress = (formData: FormData): Promise<UploadResult> => {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();

      // Track upload progress
      xhr.upload.onprogress = (event) => {
        if (event.lengthComputable) {
          const progress = (event.loaded / event.total) * 100;
          setUploadProgress(Math.round(progress));
        }
      };

      xhr.onload = () => {
        if (xhr.status >= 200 && xhr.status < 300) {
          try {
            const response = JSON.parse(xhr.responseText);
            resolve(response);
          } catch (error) {
            reject(new Error('Failed to parse response'));
          }
        } else {
          try {
            const errorResponse = JSON.parse(xhr.responseText);
            reject(new Error(errorResponse.error || `Upload failed with status ${xhr.status}`));
          } catch (parseError) {
            reject(new Error(`Upload failed with status ${xhr.status}`));
          }
        }
      };

      xhr.onerror = () => reject(new Error('Network error during upload'));
      xhr.ontimeout = () => reject(new Error('Upload timeout'));

      xhr.open('POST', '/api/upload');
      xhr.timeout = 120000; // 2 minute timeout
      xhr.send(formData);
    });
  };

  const resetUpload = () => {
    setUploadResult(null);
    setUploadProgress(0);
    setIsUploading(false);
  };

  return {
    uploadFile,
    resetUpload,
    isUploading,
    uploadProgress,
    uploadResult
  };
};

Key Concepts:

  • Progress Tracking: Real upload progress using XMLHttpRequest
  • Error Handling: Comprehensive error states and messages
  • State Management: Clean state management for upload lifecycle

6.2 Create Upload Component

Create src/components/FileUpload.tsx:

'use client';

import React, { useRef, useState } from 'react';
import { useFileUpload } from '@/hooks/useFileUpload';

interface FileUploadProps {
  userId: string;
  onUploadComplete?: (result: any) => void;
  acceptedTypes?: string[];
  maxFileSize?: number;
}

export const FileUpload: React.FC<FileUploadProps> = ({
  userId,
  onUploadComplete,
  acceptedTypes = ['audio/*', 'image/*', '.pdf'],
  maxFileSize = 20 * 1024 * 1024 // 20MB
}) => {
  const fileInputRef = useRef<HTMLInputElement>(null);
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [dragActive, setDragActive] = useState(false);
  
  const { uploadFile, resetUpload, isUploading, uploadProgress, uploadResult } = useFileUpload();

  const handleFileSelect = (file: File) => {
    // Validate file size
    if (file.size > maxFileSize) {
      alert(`File size ${(file.size / 1024 / 1024).toFixed(1)}MB exceeds ${(maxFileSize / 1024 / 1024)}MB limit`);
      return;
    }

    setSelectedFile(file);
    resetUpload();
  };

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const file = event.target.files?.[0];
    if (file) {
      handleFileSelect(file);
    }
  };

  const handleDrag = (event: React.DragEvent) => {
    event.preventDefault();
    event.stopPropagation();
    if (event.type === 'dragenter' || event.type === 'dragover') {
      setDragActive(true);
    } else if (event.type === 'dragleave') {
      setDragActive(false);
    }
  };

  const handleDrop = (event: React.DragEvent) => {
    event.preventDefault();
    event.stopPropagation();
    setDragActive(false);
    
    const file = event.dataTransfer.files?.[0];
    if (file) {
      handleFileSelect(file);
    }
  };

  const handleUpload = async () => {
    if (!selectedFile) return;

    const result = await uploadFile(selectedFile, userId);
    
    if (result.success && onUploadComplete) {
      onUploadComplete(result);
    }
  };

  const handleReset = () => {
    setSelectedFile(null);
    resetUpload();
    if (fileInputRef.current) {
      fileInputRef.current.value = '';
    }
  };

  return (
    <div className="w-full max-w-md mx-auto p-6 bg-white rounded-lg shadow-lg">
      <h3 className="text-lg font-semibold mb-4">Upload File</h3>
      
      {/* Drop Zone */}
      <div
        className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
          dragActive 
            ? 'border-blue-500 bg-blue-50' 
            : 'border-gray-300 hover:border-gray-400'
        }`}
        onDragEnter={handleDrag}
        onDragLeave={handleDrag}
        onDragOver={handleDrag}
        onDrop={handleDrop}
      >
        <input
          ref={fileInputRef}
          type="file"
          onChange={handleInputChange}
          accept={acceptedTypes.join(',')}
          className="hidden"
        />
        
        {selectedFile ? (
          <div className="space-y-2">
            <p className="text-sm font-medium">{selectedFile.name}</p>
            <p className="text-xs text-gray-500">
              {(selectedFile.size / 1024 / 1024).toFixed(2)} MB
            </p>
          </div>
        ) : (
          <div className="space-y-2">
            <p className="text-gray-600">
              Drag and drop a file here, or{' '}
              <button
                type="button"
                onClick={() => fileInputRef.current?.click()}
                className="text-blue-500 hover:text-blue-600 font-medium"
              >
                browse
              </button>
            </p>
            <p className="text-xs text-gray-400">
              Max size: {(maxFileSize / 1024 / 1024)}MB
            </p>
          </div>
        )}
      </div>

      {/* Progress Bar */}
      {isUploading && (
        <div className="mt-4">
          <div className="flex justify-between text-sm mb-1">
            <span>Uploading...</span>
            <span>{uploadProgress}%</span>
          </div>
          <div className="w-full bg-gray-200 rounded-full h-2">
            <div
              className="bg-blue-500 h-2 rounded-full transition-all duration-300"
              style={{ width: `${uploadProgress}%` }}
            />
          </div>
        </div>
      )}

      {/* Upload Result */}
      {uploadResult && (
        <div className={`mt-4 p-3 rounded ${
          uploadResult.success 
            ? 'bg-green-50 text-green-800' 
            : 'bg-red-50 text-red-800'
        }`}>
          {uploadResult.success ? (
            <div>
              <p className="font-medium">Upload successful!</p>
              <p className="text-sm">File ID: {uploadResult.fileId}</p>
            </div>
          ) : (
            <p>Error: {uploadResult.error}</p>
          )}
        </div>
      )}

      {/* Action Buttons */}
      <div className="flex gap-2 mt-4">
        {selectedFile && !isUploading && !uploadResult?.success && (
          <button
            onClick={handleUpload}
            className="flex-1 bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition-colors"
          >
            Upload File
          </button>
        )}
        
        {(selectedFile || uploadResult) && (
          <button
            onClick={handleReset}
            disabled={isUploading}
            className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-50 transition-colors disabled:opacity-50"
          >
            Reset
          </button>
        )}
      </div>
    </div>
  );
};

Important Concepts:

  • Drag and Drop: Modern file selection with drag-and-drop support
  • Progress Indication: Real-time upload progress for better user experience
  • Validation: Client-side validation for immediate feedback
  • State Management: Clear visual states for different upload phases

Step 7: Implementing Audio Recording (Bonus Feature)

Since we mentioned building a voice recording app, let's add audio recording functionality. This step shows how to integrate HTML5 Audio API with our upload system.

7.1 Create Audio Recorder Hook

Create src/hooks/useAudioRecorder.ts:

import { useState, useRef, useCallback } from 'react';

interface UseAudioRecorderReturn {
  isRecording: boolean;
  isPaused: boolean;
  recordingTime: number;
  audioURL: string | null;
  audioBlob: Blob | null;
  startRecording: () => Promise<void>;
  stopRecording: () => void;
  pauseRecording: () => void;
  resumeRecording: () => void;
  resetRecording: () => void;
}

export const useAudioRecorder = (): UseAudioRecorderReturn => {
  const [isRecording, setIsRecording] = useState(false);
  const [isPaused, setIsPaused] = useState(false);
  const [recordingTime, setRecordingTime] = useState(0);
  const [audioURL, setAudioURL] = useState<string | null>(null);
  const [audioBlob, setAudioBlob] = useState<Blob | null>(null);

  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
  const streamRef = useRef<MediaStream | null>(null);
  const chunksRef = useRef<Blob[]>([]);
  const timerRef = useRef<NodeJS.Timeout | null>(null);

  const startTimer = useCallback(() => {
    timerRef.current = setInterval(() => {
      setRecordingTime(prevTime => prevTime + 1);
    }, 1000);
  }, []);

  const stopTimer = useCallback(() => {
    if (timerRef.current) {
      clearInterval(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  const startRecording = useCallback(async () => {
    try {
      // Request microphone access
      const stream = await navigator.mediaDevices.getUserMedia({ 
        audio: {
          echoCancellation: true,
          noiseSuppression: true,
          sampleRate: 44100,
        }
      });
      
      streamRef.current = stream;
      chunksRef.current = [];

      // Create MediaRecorder
      const mediaRecorder = new MediaRecorder(stream, {
        mimeType: 'audio/webm;codecs=opus'
      });
      
      mediaRecorderRef.current = mediaRecorder;

      // Handle data available
      mediaRecorder.ondataavailable = (event) => {
        if (event.data.size > 0) {
          chunksRef.current.push(event.data);
        }
      };

      // Handle recording stop
      mediaRecorder.onstop = () => {
        const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
        const url = URL.createObjectURL(blob);
        
        setAudioBlob(blob);
        setAudioURL(url);
        setIsRecording(false);
        setIsPaused(false);
        stopTimer();
      };

      // Start recording
      mediaRecorder.start(100); // Collect data every 100ms
      setIsRecording(true);
      setRecordingTime(0);
      startTimer();

    } catch (error) {
      console.error('Error starting recording:', error);
      alert('Failed to access microphone. Please check permissions.');
    }
  }, [startTimer, stopTimer]);

  const stopRecording = useCallback(() => {
    if (mediaRecorderRef.current && isRecording) {
      mediaRecorderRef.current.stop();
      
      // Stop all tracks
      if (streamRef.current) {
        streamRef.current.getTracks().forEach(track => track.stop());
        streamRef.current = null;
      }
    }
  }, [isRecording]);

  const pauseRecording = useCallback(() => {
    if (mediaRecorderRef.current && isRecording) {
      mediaRecorderRef.current.pause();
      setIsPaused(true);
      stopTimer();
    }
  }, [isRecording, stopTimer]);

  const resumeRecording = useCallback(() => {
    if (mediaRecorderRef.current && isPaused) {
      mediaRecorderRef.current.resume();
      setIsPaused(false);
      startTimer();
    }
  }, [isPaused, startTimer]);

  const resetRecording = useCallback(() => {
    stopRecording();
    setRecordingTime(0);
    setAudioURL(null);
    setAudioBlob(null);
    stopTimer();
  }, [stopRecording, stopTimer]);

  return {
    isRecording,
    isPaused,
    recordingTime,
    audioURL,
    audioBlob,
    startRecording,
    stopRecording,
    pauseRecording,
    resumeRecording,
    resetRecording,
  };
};

7.2 Create Audio Recorder Component

Create src/components/AudioRecorder.tsx:

'use client';

import React from 'react';
import { useAudioRecorder } from '@/hooks/useAudioRecorder';
import { useFileUpload } from '@/hooks/useFileUpload';

interface AudioRecorderProps {
  userId: string;
  onUploadComplete?: (result: any) => void;
}

export const AudioRecorder: React.FC<AudioRecorderProps> = ({
  userId,
  onUploadComplete
}) => {
  const {
    isRecording,
    isPaused,
    recordingTime,
    audioURL,
    audioBlob,
    startRecording,
    stopRecording,
    pauseRecording,
    resumeRecording,
    resetRecording,
  } = useAudioRecorder();

  const { uploadFile, isUploading, uploadProgress, uploadResult } = useFileUpload();

  const formatTime = (seconds: number): string => {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
  };

  const handleUpload = async () => {
    if (!audioBlob) return;

    // Convert blob to File
    const audioFile = new File([audioBlob], `recording-${Date.now()}.webm`, {
      type: 'audio/webm'
    });

    const result = await uploadFile(audioFile, userId);
    
    if (result.success && onUploadComplete) {
      onUploadComplete(result);
    }
  };

  return (
    <div className="w-full max-w-md mx-auto p-6 bg-white rounded-lg shadow-lg">
      <h3 className="text-lg font-semibold mb-4">Audio Recorder</h3>
      
      {/* Recording Status */}
      <div className="text-center mb-4">
        <div className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm ${
          isRecording
            ? isPaused
              ? 'bg-yellow-100 text-yellow-800'
              : 'bg-red-100 text-red-800'
            : 'bg-gray-100 text-gray-600'
        }`}>
          <div className={`w-2 h-2 rounded-full ${
            isRecording && !isPaused ? 'bg-red-500 animate-pulse' : 'bg-gray-400'
          }`} />
          {isRecording 
            ? isPaused 
              ? 'Paused' 
              : 'Recording' 
            : 'Ready'
          }
        </div>
        <div className="text-2xl font-mono mt-2">
          {formatTime(recordingTime)}
        </div>
      </div>

      {/* Control Buttons */}
      <div className="flex justify-center gap-2 mb-4">
        {!isRecording && !audioURL && (
          <button
            onClick={startRecording}
            className="bg-red-500 text-white px-6 py-2 rounded-full hover:bg-red-600 transition-colors"
          >
            Start Recording
          </button>
        )}

        {isRecording && !isPaused && (
          <>
            <button
              onClick={pauseRecording}
              className="bg-yellow-500 text-white px-4 py-2 rounded hover:bg-yellow-600 transition-colors"
            >
              Pause
            </button>
            <button
              onClick={stopRecording}
              className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 transition-colors"
            >
              Stop
            </button>
          </>
        )}

        {isRecording && isPaused && (
          <>
            <button
              onClick={resumeRecording}
              className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors"
            >
              Resume
            </button>
            <button
              onClick={stopRecording}
              className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 transition-colors"
            >
              Stop
            </button>
          </>
        )}
      </div>

      {/* Audio Playback */}
      {audioURL && (
        <div className="mb-4">
          <audio
            src={audioURL}
            controls
            className="w-full"
          />
        </div>
      )}

      {/* Upload Progress */}
      {isUploading && (
        <div className="mb-4">
          <div className="flex justify-between text-sm mb-1">
            <span>Uploading...</span>
            <span>{uploadProgress}%</span>
          </div>
          <div className="w-full bg-gray-200 rounded-full h-2">
            <div
              className="bg-blue-500 h-2 rounded-full transition-all duration-300"
              style={{ width: `${uploadProgress}%` }}
            />
          </div>
        </div>
      )}

      {/* Upload Result */}
      {uploadResult && (
        <div className={`mb-4 p-3 rounded ${
          uploadResult.success 
            ? 'bg-green-50 text-green-800' 
            : 'bg-red-50 text-red-800'
        }`}>
          {uploadResult.success ? (
            <div>
              <p className="font-medium">Upload successful!</p>
              <p className="text-sm">File ID: {uploadResult.fileId}</p>
            </div>
          ) : (
            <p>Error: {uploadResult.error}</p>
          )}
        </div>
      )}

      {/* Action Buttons */}
      <div className="flex gap-2">
        {audioURL && !isUploading && !uploadResult?.success && (
          <button
            onClick={handleUpload}
            className="flex-1 bg-blue-500 text-white py-2 px-4 rounded hover:bg-blue-600 transition-colors"
          >
            Upload Recording
          </button>
        )}
        
        {audioURL && (
          <button
            onClick={resetRecording}
            disabled={isUploading}
            className="px-4 py-2 border border-gray-300 rounded hover:bg-gray-50 transition-colors disabled:opacity-50"
          >
            Reset
          </button>
        )}
      </div>
    </div>
  );
};

Important Concepts:

  • MediaRecorder API: HTML5 API for recording audio/video
  • Audio Constraints: Optimized settings for voice recording
  • Blob Handling: Converting recorded audio to uploadable format
  • User Experience: Clear visual feedback for recording states

Step 8: Using the Components

In this final step, we'll show how to integrate our upload components into a Next.js page and handle the upload results.

8.1 Create Upload Page

Create src/app/upload/page.tsx:

'use client';

import React, { useState } from 'react';
import { FileUpload } from '@/components/FileUpload';
import { AudioRecorder } from '@/components/AudioRecorder';

// Mock user ID - in a real app, get this from authentication
const MOCK_USER_ID = 'user_123';

export default function UploadPage() {
  const [uploadHistory, setUploadHistory] = useState<any[]>([]);

  const handleUploadComplete = (result: any) => {
    console.log('Upload completed:', result);
    setUploadHistory(prev => [result, ...prev]);
  };

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold text-center mb-8">
        File Upload Demo
      </h1>
      
      <div className="grid md:grid-cols-2 gap-8 mb-8">
        {/* File Upload */}
        <div>
          <h2 className="text-xl font-semibold mb-4">File Upload</h2>
          <FileUpload
            userId={MOCK_USER_ID}
            onUploadComplete={handleUploadComplete}
          />
        </div>

        {/* Audio Recorder */}
        <div>
          <h2 className="text-xl font-semibold mb-4">Audio Recorder</h2>
          <AudioRecorder
            userId={MOCK_USER_ID}
            onUploadComplete={handleUploadComplete}
          />
        </div>
      </div>

      {/* Upload History */}
      {uploadHistory.length > 0 && (
        <div className="bg-gray-50 rounded-lg p-6">
          <h3 className="text-lg font-semibold mb-4">Upload History</h3>
          <div className="space-y-2">
            {uploadHistory.map((upload, index) => (
              <div
                key={index}
                className="bg-white p-3 rounded border flex justify-between items-center"
              >
                <div>
                  <p className="font-medium">{upload.fileName}</p>
                  <p className="text-sm text-gray-500">ID: {upload.fileId}</p>
                </div>
                <div className="text-right">
                  <p className="text-green-600 font-medium">✓ Uploaded</p>
                  <p className="text-xs text-gray-500">
                    {new Date().toLocaleTimeString()}
                  </p>
                </div>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

8.2 Add to App Layout

Update your src/app/layout.tsx to include proper styling:

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

export const metadata: Metadata = {
  title: 'File Upload Demo',
  description: 'Google Cloud Storage upload with Next.js',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-gray-100 min-h-screen">
        {children}
      </body>
    </html>
  );
}

Usage Concepts:

  • Component Integration: Clean separation of file upload and audio recording
  • State Management: Centralized upload history tracking
  • User Feedback: Clear visual feedback for all upload states

Conclusion

We've successfully built a complete file upload system using Google Cloud Storage with a server proxy approach. This implementation provides:

  • Reliable uploads without CORS complications
  • Secure credential handling on the server-side
  • Real-time progress tracking for better user experience
  • Comprehensive error handling with detailed logging
  • Audio recording capabilities using HTML5 APIs

The server proxy approach we chose offers maximum reliability and security, making it ideal for production applications. While it uses server resources, the trade-offs in reliability and security make it the preferred choice for most use cases.

What's Next?

We covered the server proxy approach in this article, but there's more to explore! If you're interested in learning about the direct client upload approach (which can be faster but requires more complex CORS configuration), subscribe to our newsletter. We'll cover:

  • Direct client uploads with pre-signed URLs
  • Advanced CORS configuration techniques
  • Handling large file uploads with multipart uploads
  • Adding file processing pipelines

The complete implementation provides a solid foundation for any application requiring file uploads to Google Cloud Storage. You can extend this further by adding features like file type validation, image resizing, or automatic backup strategies.

Thanks, — Matija

4

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in