How I built a store with a real-time catalog and automatic rebuilds: the Los Luises case
Can you have real-time data (price, stock) on a statically generated site, without losing the speed of static export or depending on a person to republish?
By Pietro Fiorillo ·
In this article
The problem: a catalog that changes daily, on a site generated once
Los Luises is a sublimation and printing workshop with more than 26 years of history. Its catalog changes often: prices, offers, stock and new products. The site, though, is a Next.js static export: HTML generated once at build time, with no server behind it. If every price change needed a full build, the catalog would never be current.
Why I separated real-time reads from the rebuild
Instead of forcing everything through one path, I split the problem in two. For data that changes often (price, stock, offers), the browser listens to Firestore directly. For changes that need a new page or touch the sitemap (a new product, for example), the whole site gets regenerated.
The hook that does the real-time read is simple: it subscribes to the products collection with onSnapshot and updates state whenever something changes in Firestore, with no page reload needed.
export function useProducts() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const unsubscribe = onSnapshot(
collection(db, "products"),
(snapshot) => {
setProducts(snapshot.docs.map((doc) => doc.data() as Product));
setLoading(false);
},
(err) => {
setError(err);
setLoading(false);
}
);
return unsubscribe;
}, []);
return { products, loading, error };
}lib/useProducts.ts in the project's repository, October 2026.How the rebuild triggers itself
The other half of the system is a Cloud Function that listens to any write on products/{productId}. When it detects a change, it calls the Cloud Build API directly to start a new build, with no person needing to run anything.
exports.rebuildOnProductChange = onDocumentWritten(
{ document: "products/{productId}", region: "us-central1", memory: "256MiB" },
async (event) => {
const auth = new GoogleAuth({
scopes: ["https://www.googleapis.com/auth/cloud-platform"],
});
const client = await auth.getClient();
const projectId = await auth.getProjectId();
const body = {
source: { storageSource: { bucket, object } },
steps: buildSteps(),
timeout: "1200s",
};
await client.request({
url: `https://cloudbuild.googleapis.com/v1/projects/${projectId}/builds`,
method: "POST",
data: body,
});
}
);
// Debe reflejar los mismos steps que cloudbuild.yaml en la raíz del repo.
// Si cambiás cloudbuild.yaml, actualizá esto también.functions/index.js. The comment about cloudbuild.yaml is in the original file, I didn't add it.That comment in the code is, literally, a note I left myself: the build steps (buildSteps()) have to match the ones in cloudbuild.yaml at the repo root, and if I change one I have to remember to change the other by hand. I come back to this in the technical debt section.
How I validated this architecture
I actually ran npm run build against the repository (Next.js 16.2.12, Turbopack, with the project's real .env.local) and measured the result instead of describing it from memory. The build finished in 18.8 seconds and generated 74 static routes, including 54 product pages (38 customizable and 16 stock) and a sitemap.xml with 62 URLs, matching what the production site actually returns.
const nextConfig: NextConfig = {
output: "export",
images: {
unoptimized: true,
},
};next.config.ts. As in other projects, images.unoptimized is required alongside static export.Measurable results
I measured the following on October 18, 2026, with Lighthouse (lab data, simulated throttling) against the production site, and with a real local build.
| Metric | Result | Environment / evidence |
|---|---|---|
| Lighthouse Performance (mobile, lab) | 61 / 100 | Lighthouse 13.5.0, simulated throttling |
| LCP (lab) | 18.3 s | Same Lighthouse run, not field (CrUX) data |
| Local build time | 18.8 s | next build, Next.js 16.2.12, Turbopack |
| Static export size | 24 MB across 781 files | /out folder generated by that build |
| Static routes generated | 74 (54 product pages) | Build log |
| URLs in the sitemap | 62 | sitemap.xml generated by the build and confirmed in production |
An 18.3 second LCP is the highest of every project I measured for this series. My hypothesis, without deep browser profiling, is that two costs stack up: images.unoptimized: true (same as other static-export projects) and the product list depending on onSnapshot receiving its first Firestore snapshot before it can render anything. That's a reasonable hypothesis from the code, not a cause confirmed with profiling.
Layered security: what these rules allow, and what they don't
The products collection can be read with no authentication (it's catalog data, not sensitive) but can only be written to with a signed-in session. These are the project's real rules, not summarized or softened.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /products/{productId} {
allow read: if true;
allow write: if request.auth != null;
}
}
}firestore.rules in the project's repository.This protects against anonymous writes to the database. It says nothing about the security of the /admin panel itself (login, session handling) or other parts of the system: it's one piece of the security, not all of it.
Separating what changes constantly from what needs a new page isn't an optimization, it's the right question to ask before choosing the architecture.
My part in this project
My part in Los Luises Sublimación included the complete architecture design, full-stack development (frontend, admin panel and Cloud Function), the Firebase and Google Cloud infrastructure, and technical SEO, as part of my work at MANYA Digital.
What I'd do differently: a warning I found in my own code
Reviewing the repository for this article, I found two things I didn't remember precisely. First: the build pipeline's steps are written twice, once in the Cloud Function and once in cloudbuild.yaml, and the code itself has a comment asking for both to be updated together. That's exactly the kind of duplication that drifts out of sync over time if no one notices.
Second: the Cloud Function's runtime is pinned to Node 20, while the Cloud Build steps run on Node 22 images. Nothing breaks today, but that's two Node versions coexisting in the same pipeline, undocumented anywhere until this article.
Neither point affects the production site right now. I leave them documented because they're the kind of detail worth knowing before touching the pipeline, not after something breaks from an update made in only one place.
What I'd test next
I'd unify the pipeline definition into a single file (having the Cloud Function read cloudbuild.yaml instead of duplicating its steps in JavaScript), align the Node version between the function and the Cloud Build images, and optimize the catalog's images to bring down the measured 18.3s LCP, probably the change with the biggest real impact of the three.
Until then, the system delivers what it promises: instant price and stock, new pages with no one running a manual deploy, and a warning in the code that, now, is written down somewhere it won't get lost.
Sources
Frequently asked questions
Why not just use Next.js ISR instead of real-time Firestore?
Because the site is exported as static (output: export), and that mode doesn't support ISR: no revalidate value has any real effect in production. Real-time reads with onSnapshot solve the problem a different way, in the browser, with no dependency on a Next server.
What happens if Cloud Build fails to rebuild the site?
The public site keeps working on the last deployed version; it doesn't go down. What doesn't happen is new pages, the sitemap or structured data updating until a later build succeeds. I didn't verify an automatic failed-build alert in this entry.
Is it safe for anyone to read the products collection?
Firestore rules allow public read access only to the products collection and require authentication to write. It's the same model as a public catalog API: product data isn't sensitive, but no one can change it without logging in.
Why two systems (live reads and rebuilds) instead of one?
They solve different problems. onSnapshot updates price and stock instantly on an already-loaded page, but doesn't create new pages or update the sitemap. The rebuild does that, but takes minutes. Together they cover both instant updates and static SEO.
How long does it take for a real change to show up?
It depends on what changes. A price or stock status shows up instantly in any open tab. A new product needs the build to finish (I measured 18.8 seconds locally, not counting deploy) before it gets its own page and appears in the sitemap.
How much does it cost to maintain an architecture like this?
The cost depends on usage of Firestore, Cloud Functions, Cloud Build and Firebase Hosting, which all have generous free tiers for a catalog this size. There's no custom Node server to pay for or maintain.