Creating an Indexed Article System with Next.js and Firebase

Firestore model (recommended)

Collection: articles

Document id: index (string or numeric converted to string)

Document fields:

{
  "index": 1,                      // numeric index (also used as doc id: "1")
  "title": "How to Build with Next.js",
  "slug": "how-to-build-with-nextjs",
  "author": "Rohit Kumar",
  "body": "<p>Article markdown or HTML content...</p>",
  "excerpt": "Short summary for listing",
  "tags": ["nextjs", "react", "firebase"],
  "coverImage": "https://.../cover.jpg",
  "status": "published",           // draft / published
  "createdAt": 1696020000000,      // timestamp (ms) or Firestore Timestamp
  "updatedAt": 1696020000000
}

1) Firebase client init (firebaseClient.js)

Create this at src/lib/firebaseClient.js and replace config values with your Firebase project’s config.

// src/lib/firebaseClient.js
import { initializeApp, getApps } from "firebase/app";
import { getFirestore, serverTimestamp } from "firebase/firestore";
import { getAuth } from "firebase/auth";

const firebaseConfig = {
  apiKey: "YOUR_API_KEY",
  authDomain: "YOUR_PROJECT.firebaseapp.com",
  projectId: "YOUR_PROJECT_ID",
  storageBucket: "YOUR_PROJECT.appspot.com",
  messagingSenderId: "SENDER_ID",
  appId: "APP_ID"
};

let app;
if (!getApps().length) {
  app = initializeApp(firebaseConfig);
} else {
  app = getApps()[0];
}

export const db = getFirestore(app);
export const auth = getAuth(app);
export const now = () => serverTimestamp();

2) React component: ArticleForm — create / post an article (index as id)

This is a simple form that writes to articles/{index} using setDoc. It validates index uniqueness (optionally).

// src/components/ArticleForm.jsx
import React, { useState } from "react";
import { doc, setDoc, getDoc, serverTimestamp } from "firebase/firestore";
import { db } from "@/lib/firebaseClient";

export default function ArticleForm() {
  const [index, setIndex] = useState("");
  const [title, setTitle] = useState("");
  const [slug, setSlug] = useState("");
  const [excerpt, setExcerpt] = useState("");
  const [body, setBody] = useState("");
  const [author, setAuthor] = useState("");
  const [status, setStatus] = useState("draft");
  const [tags, setTags] = useState("");
  const [loading, setLoading] = useState(false);
  const [message, setMessage] = useState("");

  // Helper to convert tags string to array
  const tagsToArray = (s) => s.split(",").map(t => t.trim()).filter(Boolean);

  async function handleSubmit(e) {
    e.preventDefault();
    if (!index || !title) {
      setMessage("Index and title are required.");
      return;
    }

    setLoading(true);
    setMessage("");

    try {
      const docId = String(index);
      const ref = doc(db, "articles", docId);

      // OPTIONAL: check if article with same index already exists
      const exists = await getDoc(ref);
      if (exists.exists()) {
        // choose to overwrite or prevent - here we prevent
        setLoading(false);
        setMessage(`Article with index ${docId} already exists. Choose a different index or delete existing.`);
        return;
      }

      const payload = {
        index: Number(index),
        title,
        slug: slug || title.toLowerCase().replace(/s+/g, "-"),
        excerpt,
        body,
        author,
        status,
        tags: tagsToArray(tags),
        coverImage: "", // add a URL if available
        createdAt: serverTimestamp(),
        updatedAt: serverTimestamp()
      };

      await setDoc(ref, payload);
      setMessage(`Article ${docId} saved successfully.`);
      // optionally clear form
      setIndex("");
      setTitle("");
      setSlug("");
      setExcerpt("");
      setBody("");
      setAuthor("");
      setTags("");
    } catch (err) {
      console.error(err);
      setMessage("Error saving article: " + (err.message || err));
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
      <div>
        <label>Index (number)</label>
        <input value={index} onChange={e=>setIndex(e.target.value)} type="number" className="w-full" />
      </div>

      <div>
        <label>Title</label>
        <input value={title} onChange={e=>setTitle(e.target.value)} className="w-full" />
      </div>

      <div>
        <label>Slug (optional)</label>
        <input value={slug} onChange={e=>setSlug(e.target.value)} className="w-full" />
      </div>

      <div>
        <label>Excerpt</label>
        <textarea value={excerpt} onChange={e=>setExcerpt(e.target.value)} className="w-full" />
      </div>

      <div>
        <label>Body (HTML or Markdown)</label>
        <textarea value={body} onChange={e=>setBody(e.target.value)} className="w-full h-40" />
      </div>

      <div>
        <label>Author</label>
        <input value={author} onChange={e=>setAuthor(e.target.value)} className="w-full" />
      </div>

      <div>
        <label>Tags (comma separated)</label>
        <input value={tags} onChange={e=>setTags(e.target.value)} className="w-full" />
      </div>

      <div>
        <label>Status</label>
        <select value={status} onChange={e=>setStatus(e.target.value)} className="w-full">
          <option value="draft">Draft</option>
          <option value="published">Published</option>
        </select>
      </div>

      <div>
        <button type="submit" disabled={loading} className="px-4 py-2 bg-blue-600 text-white">
          {loading ? "Saving..." : "Save Article"}
        </button>
      </div>

      {message && <p className="mt-2">{message}</p>}
    </form>
  );
}

3) Sample article (JSON)

You can use this to quickly seed Firestore manually or via the form:

{
  "index": 1,
  "title": "Getting Started with Next.js + Firebase",
  "slug": "getting-started-nextjs-firebase",
  "author": "Rohit Kumar",
  "body": "<h2>Intro</h2><p>Next.js + Firebase is a great combo...</p>",
  "excerpt": "Quick guide to combine Next.js with Firebase.",
  "tags": ["nextjs","firebase","react"],
  "coverImage": "https://example.com/cover.jpg",
  "status": "published",
  "createdAt": 1696030000000,
  "updatedAt": 1696030000000
}

4) Read / fetch an article by index

Example helper to fetch an article from Firestore by index (document id = index string).

// src/lib/articles.js
import { doc, getDoc } from "firebase/firestore";
import { db } from "./firebaseClient";

export async function fetchArticleByIndex(index) {
  const docId = String(index);
  const ref = doc(db, "articles", docId);
  const snap = await getDoc(ref);
  if (!snap.exists()) return null;
  // Convert Firestore timestamp to JS date if needed
  const data = snap.data();
  return { id: snap.id, ...data };
}

Usage in a React component:

import React, { useEffect, useState } from "react";
import { fetchArticleByIndex } from "@/lib/articles";

export default function ArticlePage({ index }) {
  const [article, setArticle] = useState(null);
  useEffect(() => {
    fetchArticleByIndex(index).then(setArticle);
  }, [index]);

  if (!article) return <div>Loading...</div>;
  return (
    <article>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.body }} />
      <p>Author: {article.author}</p>
    </article>
  );
}

5) Example Firestore security rules (basic)

Put this in your Firebase console → Firestore → Rules. Adjust for your auth model. This example allows authenticated users to create/update their own articles; you can tighten it.

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /articles/{articleId} {
      allow read: if true; // public read
      allow create, update, delete: if request.auth != null
        // Optional: restrict that index field must match doc id
        && request.resource.data.index == int(articleId)
        // maybe require certain fields present
        && request.resource.data.title is string
        && request.resource.data.body is string;
    }
  }
}

Note: int(articleId) will fail if docId is non-numeric — adjust to string comparison if you use string slugs.

6) Notes & best practices

  • Doc id as index: using index as document id makes URLs predictable (articles/1). If you expect renumbering, consider using a stable slug or UUID and keep index as a separate numeric field.
  • Timestamps: prefer Firestore serverTimestamp() for createdAt/updatedAt to avoid client clock issues.
  • HTML vs Markdown: store Markdown in body and render on the client with a Markdown renderer (safer). If storing HTML, sanitize before rendering.
  • Images: for cover images, use Firebase Storage and store the public URL in coverImage.
  • Validation: validate index uniqueness if you rely on numeric indices.
  • Admin writes: if you need restricted writes (only admins can create), use a server-side endpoint or Cloud Function with the Admin SDK.

7) If you want a Next.js API route (server-side write using Admin SDK)

I can provide that too — it’s safer for admin-only article creation. For now I kept the example client-side for quick posting.

If you want, I can:

  • Convert the form into a full Next.js page with Tailwind UI.
  • Add image upload to Firebase Storage and automatically save coverImage URL.
  • Provide a server-side (Admin SDK) API route for admin-only article posting.
  • Give a migration script to bulk import sample articles.

Tell me which of the above you’d like next and I’ll give the code.

Leave a comment

Your email address will not be published. Required fields are marked *