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

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
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 usingstartAfter.
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.





