November 17, 202512 min

Photo Micro‑blog with Auth, Storage, and Rules using Nuxt 4 + TypeScript + Firebase

Build a tiny Instagram‑style photo micro‑blog with Google Sign‑In, secure Storage uploads, and strict Security Rules using Nuxt 4, TypeScript, and Firebase v11.

Photo Micro‑blog with Auth, Storage, and Rules using Nuxt 4 + TypeScript + Firebase

nuxt-firebase-photo-microblog

Build a minimal photo micro‑blog: sign in with Google, upload photos to Firebase Storage, and enforce strict Security Rules. We’ll use Nuxt 4 (latest), TypeScript, and Firebase Web SDK v11.

Tags: Nuxt 4, TypeScript, Firebase v11, Auth, Storage, Firestore, Security Rules

Time to read: 12 min

What you’ll build

  • Google Sign‑In (Firebase Auth)
  • Upload photos to Firebase Storage with size and MIME checks
  • Store post metadata in Firestore
  • Real‑time feed
  • Production‑grade Security Rules tying Firestore docs to Storage object paths

Highlights of recent platform features we use:

  • Nuxt 4 app structure and typed runtime config by default
  • Firebase Web SDK v11 modular APIs (tree‑shakable, modern ESM)

Prerequisites

  • Node.js 18 or higher
  • A Firebase project (console.firebase.google.com)
  • Basic Vue/Nuxt familiarity

1) Create the Nuxt 4 app

npx nuxi init photo-microblog-nuxt4
cd photo-microblog-nuxt4
npm install

Run it:

npm run dev

Open http://localhost:3000

2) Add Firebase (Web SDK v11)

Install Firebase:

npm i firebase

Create .env (replace values from Firebase console > Project settings > General > Your apps):

# .env
NUXT_PUBLIC_FIREBASE_API_KEY=YOUR_API_KEY
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=YOUR_PROJECT.firebaseapp.com
NUXT_PUBLIC_FIREBASE_PROJECT_ID=YOUR_PROJECT_ID
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=YOUR_PROJECT.appspot.com
NUXT_PUBLIC_FIREBASE_APP_ID=YOUR_APP_ID
NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID=G-XXXXXXX

Add typed runtime config:

// nuxt.config.ts
export default defineNuxtConfig({
  devtools: { enabled: true },
  typescript: { strict: true },
  runtimeConfig: {
    public: {
      firebase: {
        apiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY,
        authDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
        projectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID,
        storageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
        appId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID,
        measurementId: process.env.NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID
      }
    }
  }
})

3) Initialize Firebase in a client plugin

// plugins/firebase.client.ts
import { initializeApp, getApps, type FirebaseApp } from 'firebase/app'
import { getAuth, type Auth, GoogleAuthProvider } from 'firebase/auth'
import { getFirestore, type Firestore } from 'firebase/firestore'
import { getStorage, type FirebaseStorage } from 'firebase/storage'

declare module '#app' {
  interface NuxtApp {
    $firebase: {
      app: FirebaseApp
      auth: Auth
      db: Firestore
      storage: FirebaseStorage
      GoogleAuthProvider: typeof GoogleAuthProvider
    }
  }
}
declare module 'vue' {
  interface ComponentCustomProperties {
    $firebase: NuxtApp['$firebase']
  }
}

export default defineNuxtPlugin(() => {
  const cfg = useRuntimeConfig().public.firebase
  const app = getApps().length ? getApps()[0] : initializeApp(cfg)
  const auth = getAuth(app)
  auth.useDeviceLanguage()
  const db = getFirestore(app)
  const storage = getStorage(app)

  return {
    provide: {
      firebase: { app, auth, db, storage, GoogleAuthProvider }
    }
  }
})

4) Auth composable (Google Sign‑In)

// composables/useAuth.ts
import { onMounted } from 'vue'
import { onAuthStateChanged, signInWithPopup, signOut, type User } from 'firebase/auth'

export function useAuth() {
  const { $firebase } = useNuxtApp()
  const user = useState<User | null>('user', () => null)

  onMounted(() => {
    const off = onAuthStateChanged($firebase.auth, (u) => (user.value = u))
    onBeforeUnmount(() => off())
  })

  const signInWithGoogle = async () => {
    const provider = new $firebase.GoogleAuthProvider()
    await signInWithPopup($firebase.auth, provider)
  }

  const signOutUser = async () => {
    await signOut($firebase.auth)
  }

  return { user, signInWithGoogle, signOutUser }
}

5) A Post type

// types/post.ts
export interface Post {
  id: string
  owner: string
  caption: string
  url: string
  storagePath: string
  createdAt: any // Firestore Timestamp
}

6) Home page with uploader and real‑time feed

This single page handles sign‑in, upload, and listing posts.

<!-- pages/index.vue -->
<script setup lang="ts">
import type { Post } from '~/types/post'
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useAuth } from '~/composables/useAuth'
import {
  collection, addDoc, updateDoc, doc, onSnapshot,
  orderBy, query, limit, serverTimestamp
} from 'firebase/firestore'
import { ref as storageRef, uploadBytesResumable, getDownloadURL } from 'firebase/storage'

const { $firebase } = useNuxtApp()
const { user, signInWithGoogle, signOutUser } = useAuth()

const caption = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const progress = ref(0)

const posts = ref<Post[]>([])

onMounted(() => {
  const q = query(collection($firebase.db, 'posts'), orderBy('createdAt', 'desc'), limit(50))
  const off = onSnapshot(q, (snap) => {
    posts.value = snap.docs.map(d => ({ id: d.id, ...(d.data() as any) } as Post))
  })
  onBeforeUnmount(() => off())
})

const canUpload = computed(() => !!user.value && !uploading.value)

async function chooseFile() {
  fileInput.value?.click()
}

async function onFileChange(e: Event) {
  const input = e.target as HTMLInputElement
  const file = input.files?.[0]
  if (!file) return
  if (!file.type.startsWith('image/')) {
    alert('Please choose an image.')
    input.value = ''
    return
  }
  if (file.size > 5 * 1024 * 1024) {
    alert('Max size is 5MB.')
    input.value = ''
    return
  }
  await uploadPhoto(file)
  input.value = ''
}

async function uploadPhoto(file: File) {
  if (!user.value) {
    alert('Sign in first')
    return
  }
  uploading.value = true
  progress.value = 0

  // 1) Create Firestore doc with minimal data
  const postsCol = collection($firebase.db, 'posts')
  const draftRef = await addDoc(postsCol, {
    owner: user.value.uid,
    caption: caption.value || '',
    createdAt: serverTimestamp(),
    url: '',
    storagePath: ''
  })

  try {
    // 2) Upload to Storage with postId‑tied path
    const path = `users/${user.value.uid}/posts/${draftRef.id}.jpg`
    const sRef = storageRef($firebase.storage, path)
    const task = uploadBytesResumable(sRef, file, { contentType: file.type })

    await new Promise<void>((resolve, reject) => {
      task.on(
        'state_changed',
        (snap) => {
          if (snap.totalBytes) {
            progress.value = Math.round((snap.bytesTransferred / snap.totalBytes) * 100)
          }
        },
        reject,
        () => resolve()
      )
    })

    const url = await getDownloadURL(sRef)

    // 3) Finalize Firestore doc with URL and storagePath
    await updateDoc(doc($firebase.db, 'posts', draftRef.id), {
      url,
      storagePath: path
    })

    caption.value = ''
  } catch (err: any) {
    console.error(err)
    alert('Upload failed: ' + (err?.message || 'unknown error'))
  } finally {
    uploading.value = false
    progress.value = 0
  }
}
</script>

<template>
  <div class="container">
    <header class="topbar">
      <h1>Nuxt 4 Photo Micro‑blog</h1>
      <div>
        <button v-if="!user" @click="signInWithGoogle">Sign in with Google</button>
        <div v-else class="auth">
          <span>Hi, {{ user.displayName || 'friend' }}</span>
          <button @click="signOutUser">Sign out</button>
        </div>
      </div>
    </header>

    <section class="composer" v-if="user">
      <input
        ref="fileInput"
        type="file"
        accept="image/*"
        class="hidden"
        @change="onFileChange"
      />
      <input
        type="text"
        placeholder="Say something about your photo…"
        v-model="caption"
      />
      <button :disabled="!canUpload" @click="chooseFile">
        {{ uploading ? `Uploading ${progress}%` : 'Choose image & Upload' }}
      </button>
    </section>

    <section class="feed">
      <article v-for="p in posts" :key="p.id" class="post">
        <img v-if="p.url" :src="p.url" alt="post image" />
        <div class="meta">
          <p class="caption">{{ p.caption }}</p>
          <small>by {{ p.owner }}</small>
        </div>
      </article>
      <p v-if="!posts.length" class="empty">No posts yet. Be the first!</p>
    </section>
  </div>
</template>

<style scoped>
.container { max-width: 780px; margin: 0 auto; padding: 24px; }
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.auth { display: inline-flex; gap: 8px; align-items: center; }
.composer { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; margin: 16px 0 24px; }
.hidden { display: none; }
.feed { display: grid; gap: 16px; }
.post img { width: 100%; height: auto; border-radius: 8px; object-fit: cover; }
.meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.caption { font-weight: 600; }
.empty { opacity: 0.7; text-align: center; margin-top: 48px; }
button { padding: 8px 12px; border-radius: 6px; border: 1px solid #ccc; background: #0ea5e9; color: white; cursor: pointer; }
button[disabled] { opacity: 0.6; cursor: not-allowed; }
input[type="text"] { padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px; }
</style>

7) Enable Firebase features

In Firebase Console:

  • Authentication > Sign‑in method: enable Google
  • Firestore Database: create database (production mode or test), choose region
  • Storage: initialize default bucket

8) Security Rules

These lock down Firestore and Storage so users can only write to their own post docs and files, with strict type/size/MIME checks.

Firestore rules:

// Firestore rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /posts/{postId} {
      // Public feed; reads are open. Lock this down if needed.
      allow read: if true;

      // Create: user creates a "draft" doc (url/storagePath empty)
      allow create: if request.auth != null
        && request.resource.data.keys().hasOnly(['owner','caption','createdAt','url','storagePath'])
        && request.resource.data.owner == request.auth.uid
        && request.resource.data.caption is string
        && request.resource.data.createdAt is timestamp
        && request.resource.data.url == ''
        && request.resource.data.storagePath == '';

      // Update: only owner can update, and only modify caption/url/storagePath.
      // Ensure storagePath matches the canonical path derived from uid + postId.
      allow update: if request.auth != null
        && resource.data.owner == request.auth.uid
        && request.resource.data.keys().hasOnly(['owner','caption','createdAt','url','storagePath'])
        && request.resource.data.owner == resource.data.owner
        && request.resource.data.createdAt == resource.data.createdAt
        && request.resource.data.storagePath.matches('^users/' + request.auth.uid + '/posts/' + postId + '\\.jpg$');

      // Delete: only owner
      allow delete: if request.auth != null && resource.data.owner == request.auth.uid;
    }
  }
}

Storage rules:

// Storage rules
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    // Publicly readable images, but only owners can write to their own path
    match /users/{userId}/posts/{postId}.jpg {
      allow read: if true;
      allow write: if request.auth != null
        && request.auth.uid == userId
        && request.resource != null
        && request.resource.size < 5 * 1024 * 1024
        && request.resource.contentType.matches('image/.*');
    }

    // Optional: allow public read for other assets if you add any
    match /{allPaths=**} {
      allow read: if true;
    }
  }
}

Publish both rule sets in the console. If you use the CLI, replicate these into firestore.rules and storage.rules files and deploy with firebase deploy --only firestore:rules,storage.

9) Try it out

  • Start the app: npm run dev
  • Sign in with Google
  • Enter a caption, choose an image (< 5 MB), and upload
  • See the feed update instantly

Open DevTools Network tab to watch the resumable upload and Firestore real‑time updates.

10) Optional hardening and polish

  • App Check: In Firebase Console enable App Check (reCAPTCHA Enterprise) for Web, then initialize in your firebase client plugin. This helps mitigate abuse of Storage and Firestore from non‑legit clients.
  • Image transforms: For thumbnails, consider Cloud Storage-triggered Functions writing optimized variants, and store those URLs on the post doc.
  • Pagination: Replace limit(50) with cursor‑based pagination using startAfter.

Conclusion

You built a Nuxt 4 + TypeScript photo micro‑blog powered by Firebase v11:

  • Google Auth for sign‑in
  • Secure Storage uploads with size/MIME guards
  • Firestore metadata driving a real‑time feed
  • Rules tying each Firestore post to its Storage object path

This foundation is production‑ready and easy to extend with comments, likes, and serverless image processing.