{"id":40176,"date":"2026-07-07T07:00:00","date_gmt":"2026-07-07T14:00:00","guid":{"rendered":"https:\/\/cloudinary.com\/blog\/?p=40176"},"modified":"2026-07-08T14:15:15","modified_gmt":"2026-07-08T21:15:15","slug":"community-photo-wall-tanstack-start-cloudinary-ai-moderation","status":"publish","type":"post","link":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation","title":{"rendered":"How to Create a Community Photo Wall With TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations"},"content":{"rendered":"<div class=\"wp-block-cloudinary-markdown \"><p>User-generated content (UGC) is an exciting part of building a community product, but can also be the most intimidating. Users upload enormous files, wrong formats, portraits that crop terribly, and sometimes inappropriate content that should never be on your platform at all.<\/p>\n<p>This guide walks you through building a <strong>community photo wall<\/strong> that handles all of that automatically. With Cloudinary, you can resize, watermark, and face-crop every image before it ever enters your storage. WebPurify then scans it for inappropriate content in the background. A moderation queue lets you override anything the AI gets wrong. And because we use Cloudinary\u2019s context fields as our metadata store, the build runs on Vercel with zero database required.<\/p>\n<ul>\n<li>\n<strong>Live demo:<\/strong> <a href=\"https:\/\/tanstack-start-community-photo-wall.vercel.app\/\">https:\/\/tanstack-start-community-photo-wall.vercel.app\/<\/a>\n<\/li>\n<li>\n<strong>Source code:<\/strong> <a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\">https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall<\/a>\n<\/li>\n<\/ul>\n<h2>What You\u2019ll Build<\/h2>\n<p>You\u2019ll build:<\/p>\n<ul>\n<li>An upload page where community members submit photos.<\/li>\n<li>A Cloudinary pipeline that automatically resizes, face-crops, watermarks, and converts every upload to WebP \u2014 before it\u2019s stored.<\/li>\n<li>WebPurify AI moderation that runs in the background on every upload.<\/li>\n<li>A moderation queue where you can approve, reject, or override the AI decision.<\/li>\n<li>A public gallery that shows only approved photos.<\/li>\n<li>Badges on every photo showing whether it was approved by the AI or a human reviewer.<\/li>\n<li>A beautiful lightbox with an ambient glow effect using Cloudinary image effects.<\/li>\n<li>Persistent metadata stored entirely in Cloudinary (no separate database, works perfectly on Vercel).<\/li>\n<\/ul>\n<h2>Tech Stack<\/h2>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Tool<\/th>\n<th>What It Does<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><a href=\"https:\/\/tanstack.com\/start\">TanStack Start<\/a><\/td>\n<td>Full-stack React framework \u2014 handles routing and server functions<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/tanstack.com\/router\">TanStack Router<\/a><\/td>\n<td>File-based routing with type safety<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/cloudinary.com\/\">Cloudinary<\/a><\/td>\n<td>Image upload, transformation, moderation, and metadata storage<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/cloudinary.com\/documentation\/webpurify_image_moderation_add-on\">WebPurify add-on<\/a><\/td>\n<td>AI-powered image moderation integrated directly into Cloudinary<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/nitro.build\/\">Nitro<\/a><\/td>\n<td>Deployment adapter for Vercel<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/tailwindcss.com\/\">Tailwind CSS v4<\/a><\/td>\n<td>Styling<\/td>\n<\/tr>\n<tr>\n<td><a href=\"https:\/\/ui.shadcn.com\/\">ShadCN UI<\/a><\/td>\n<td>Accessible component primitives<\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h2>Step 1: Set Up Your Cloudinary Account<\/h2>\n<p>If you don\u2019t have a Cloudinary account, create a free one at <a href=\"https:\/\/cloudinary.com\/\">cloudinary.com<\/a>. Once you\u2019re in, grab your credentials from the <strong>Dashboard<\/strong>: Cloud name, API key, and API secret.<\/p>\n<p>You\u2019ll need these in a moment.<\/p>\n<h3>Enable the <a href=\"https:\/\/cloudinary.com\/documentation\/webpurify_image_moderation_addon\">WebPurify Add-on<\/a><\/h3>\n<p>WebPurify is a Cloudinary add-on that scans images for inappropriate content automatically. Go to <strong>Add-ons<\/strong> in your Cloudinary console and activate the free WebPurify plan. This is what powers the automatic AI moderation in this app.<\/p>\n<h2>Step 2: Create an Upload Preset<\/h2>\n<p>An <a href=\"https:\/\/cloudinary.com\/documentation\/create_upload_presets_in_console_tutorial#banner\">upload preset<\/a> is a saved collection of settings that Cloudinary applies to every upload that references it. Instead of sending transformation instructions with every upload request, you configure them once in the preset and Cloudinary handles the rest.<\/p>\n<p>Go to <strong>Settings \u2192 Upload \u2192 Upload presets \u2192 Add upload preset<\/strong>.<\/p>\n<h3>General Tab<\/h3>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Field<\/th>\n<th>Value<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Preset name<\/td>\n<td><code>community_photo_wall<\/code><\/td>\n<\/tr>\n<tr>\n<td>Signing mode<\/td>\n<td><strong>Signed<\/strong><\/td>\n<\/tr>\n<tr>\n<td>Folder<\/td>\n<td><code>community-photo-wall<\/code><\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<h3>Upload Tab<\/h3>\n<p>Under <strong>Tags<\/strong>, add <code>ugc<\/code> and <code>photo-wall<\/code> separated by commas. The <code>photo-wall<\/code> tag is how the app finds all photos later \u2014 you\u2019ll query Cloudinary\u2019s API for all assets tagged <code>photo-wall<\/code> instead of using a database.<\/p>\n<h3>Incoming Transformations Tab<\/h3>\n<p>This is the most important part. Incoming transformations run <strong>before<\/strong> the image is stored. The file that lands in your Cloudinary account is already the processed version. There are no per-request compute costs later.<\/p>\n<p>Add a chained transformation with these two steps in order:<\/p>\n<h4>1. Resize and Optimize<\/h4>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Parameter<\/th>\n<th>Value<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Crop mode<\/td>\n<td><code>fill<\/code><\/td>\n<\/tr>\n<tr>\n<td>Width<\/td>\n<td><code>800<\/code><\/td>\n<\/tr>\n<tr>\n<td>Height<\/td>\n<td><code>800<\/code><\/td>\n<\/tr>\n<tr>\n<td>Gravity<\/td>\n<td><code>auto:faces<\/code><\/td>\n<\/tr>\n<tr>\n<td>Quality<\/td>\n<td><code>auto<\/code><\/td>\n<\/tr>\n<tr>\n<td>Format<\/td>\n<td><code>webp<\/code><\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p><code>g_auto:faces<\/code> tells Cloudinary to detect faces in the image and centre the crop on them. Portrait photos always look good because the person\u2019s face stays in frame.<\/p>\n<h4>2. Watermark<\/h4>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Parameter<\/th>\n<th>Value<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Overlay<\/td>\n<td>Your logo <code>public_id<\/code> (or leave for text fallback)<\/td>\n<\/tr>\n<tr>\n<td>Gravity<\/td>\n<td><code>south_east<\/code><\/td>\n<\/tr>\n<tr>\n<td>X \/ Y<\/td>\n<td><code>12<\/code> \/ <code>12<\/code><\/td>\n<\/tr>\n<tr>\n<td>Width<\/td>\n<td><code>130<\/code><\/td>\n<\/tr>\n<tr>\n<td>Opacity<\/td>\n<td><code>60<\/code><\/td>\n<\/tr>\n<tr>\n<td>Effect<\/td>\n<td><code>brightness:20<\/code><\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>If you don\u2019t have a logo uploaded to Cloudinary yet, the app falls back to a <code>\u00a9 PhotoWall<\/code> text watermark automatically.<\/p>\n<h3>Moderation tab<\/h3>\n<p>Set <strong>Moderation<\/strong> to <strong>WebPurify<\/strong>. Every upload that uses this preset will be automatically scanned. The result (<code>approved<\/code> or <code>rejected<\/code>) is stored as Cloudinary metadata and can be fetched via the Admin API.<\/p>\n<p>Click <strong>Save<\/strong>.<\/p>\n<h2>Step 3: Configure Environment Variables<\/h2>\n<p>Create a <code>.env.local<\/code> file in your project root:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\">CLOUDINARY_CLOUD_NAME=your_cloud_name\nCLOUDINARY_API_KEY=your_api_key\nCLOUDINARY_API_SECRET=your_api_secret\nCLOUDINARY_UPLOAD_PRESET=community_photo_wall\n\n<span class=\"hljs-comment\"># Optional: public_id of a logo image in your Cloudinary account<\/span>\nCLOUDINARY_WATERMARK_PUBLIC_ID=\n\n<span class=\"hljs-comment\"># Exposed to the browser for delivery URL building (no secret needed)<\/span>\nVITE_CLOUDINARY_CLOUD_NAME=your_cloud_name\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">PHP<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">php<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Notice that <code>VITE_CLOUDINARY_CLOUD_NAME<\/code> is separate from <code>CLOUDINARY_CLOUD_NAME<\/code>. Cloudinary delivery URLs are completely public \u2014 you only need the cloud name, never the API secret. By prefixing with <code>VITE_<\/code>, TanStack Start\/Vite exposes it to the browser bundle safely.<\/p>\n<h2>Step 4: The Cloudinary Library<\/h2>\n<p>All Cloudinary Node.js SDK calls live in one file: <a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/lib\/cloudinary.ts\"><code>src\/lib\/cloudinary.ts<\/code><\/a>.<\/p>\n<p>This is intentional. TanStack Start\u2019s bundler strips server-only code from the client bundle, but only if module imports are cleanly separated. If you import the Cloudinary SDK anywhere that the browser might load, you\u2019ll get a runtime error. Keeping everything in one file makes the boundary obvious.<\/p>\n<p>The file does four things:<\/p>\n<h3>1. Configure the SDK<\/h3>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">cloudinary<\/span><span class=\"hljs-selector-class\">.config<\/span>({\n  <span class=\"hljs-attribute\">cloud_name<\/span>: process.env&#91;<span class=\"hljs-string\">\"CLOUDINARY_CLOUD_NAME\"<\/span>],\n  api_key: process.env&#91;<span class=\"hljs-string\">\"CLOUDINARY_API_KEY\"<\/span>],\n  api_secret: process.env&#91;<span class=\"hljs-string\">\"CLOUDINARY_API_SECRET\"<\/span>],\n  secure: true,\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<h3>2. Upload a Photo<\/h3>\n<p><code>uploadToCloudinary<\/code> takes a raw file buffer and streams it to Cloudinary using <code>upload_stream<\/code>. It passes the upload preset (which triggers all the incoming transformations automatically) and stores app metadata as Cloudinary context fields:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">context<\/span>: {\n  <span class=\"hljs-attribute\">pw_id<\/span>: metadata.id,\n  pw_title: <span class=\"hljs-built_in\">safeCtxValue<\/span>(metadata.title),\n  pw_status: <span class=\"hljs-string\">\"pending\"<\/span>,\n  pw_original_size: <span class=\"hljs-built_in\">String<\/span>(metadata.originalSize),\n  pw_moderation_source: <span class=\"hljs-string\">\"webpurify\"<\/span>,\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>Context fields are key-value pairs stored directly on the Cloudinary asset. This is how the app persists photo metadata without a database \u2014 more on this in a moment.<\/p>\n<blockquote>\n<p><a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/lib\/cloudinary.ts\">View the full upload function on GitHub<\/a>.<\/p>\n<\/blockquote>\n<h3>3. Fetch All Photos<\/h3>\n<p><code>getAllPhotosFromCloudinary<\/code> uses the Cloudinary Admin API to fetch all assets tagged <code>photo-wall<\/code>:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> result = <span class=\"hljs-keyword\">await<\/span> cloudinary.api.resources_by_tag(<span class=\"hljs-string\">\"photo-wall\"<\/span>, {\n  <span class=\"hljs-attr\">context<\/span>: <span class=\"hljs-literal\">true<\/span>,\n  <span class=\"hljs-attr\">max_results<\/span>: <span class=\"hljs-number\">100<\/span>,\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The <code>context: true<\/code> option includes all the <code>pw_*<\/code> fields in the response. Each resource is converted to a <code>Photo<\/code> object using the context values.<\/p>\n<h3>4. Update a Photo\u2019s Status<\/h3>\n<p><code>updatePhotoStatusOnCloudinary<\/code> uses <code>uploader.add_context<\/code> to update individual context keys without touching the rest:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">await<\/span> cloudinary.uploader.add_context(\n  <span class=\"hljs-string\">`pw_status=<span class=\"hljs-subst\">${status}<\/span>|pw_moderation_source=<span class=\"hljs-subst\">${source}<\/span>`<\/span>,\n  &#91;publicId]\n);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>This is the equivalent of a database <code>UPDATE<\/code> \u2014 but it\u2019s just a Cloudinary API call.<\/p>\n<h2>Step 5: Cloudinary as Your Database<\/h2>\n<p>Most tutorials reach for a database immediately. This app skips it entirely by using Cloudinary\u2019s <strong>context fields<\/strong> as the metadata store.<\/p>\n<p>Every photo asset in Cloudinary stores these fields:<\/p>\n<figure class=\"table-wrapper\"><table>\n<thead>\n<tr>\n<th>Context key<\/th>\n<th>What it holds<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><code>pw_id<\/code><\/td>\n<td>The app\u2019s unique ID for this photo<\/td>\n<\/tr>\n<tr>\n<td><code>pw_title<\/code><\/td>\n<td>The user-supplied caption<\/td>\n<\/tr>\n<tr>\n<td><code>pw_status<\/code><\/td>\n<td><code>pending<\/code>, <code>approved<\/code>, or <code>rejected<\/code><\/td>\n<\/tr>\n<tr>\n<td><code>pw_original_size<\/code><\/td>\n<td>Original file size in bytes before upload<\/td>\n<\/tr>\n<tr>\n<td><code>pw_moderation_source<\/code><\/td>\n<td><code>webpurify<\/code> or <code>human<\/code><\/td>\n<\/tr>\n<\/tbody>\n<\/table><\/figure>\n<p>When the gallery loads, it calls <code>resources_by_tag(&quot;photo-wall&quot;)<\/code> and reads these fields off the response. When a moderator approves a photo, it calls <code>add_context<\/code> to update <code>pw_status<\/code>. (The plus side: no database migrations, connection strings, or Vercel-incompatible file writes.)<\/p>\n<p>This works on Vercel\u2019s ephemeral serverless environment because the data lives in Cloudinary, not in the server\u2019s memory or filesystem.<\/p>\n<h2>Step 6: Chunked Upload<\/h2>\n<p>Uploading large files as a single base64-encoded string runs into two problems: memory spikes and browser timeouts. The solution is to split the file into chunks on the client, send them one at a time, and assemble them on the server.<\/p>\n<p>The upload form splits the file into 1 MB chunks:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> totalChunks = <span class=\"hljs-built_in\">Math<\/span>.max(<span class=\"hljs-number\">1<\/span>, <span class=\"hljs-built_in\">Math<\/span>.ceil(bytes.length \/ CHUNK_SIZE));\n\n<span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">let<\/span> i = <span class=\"hljs-number\">0<\/span>; i &lt; totalChunks; i++) {\n  <span class=\"hljs-keyword\">const<\/span> chunk = bytes.slice(i * CHUNK_SIZE, (i + <span class=\"hljs-number\">1<\/span>) * CHUNK_SIZE);\n  <span class=\"hljs-keyword\">const<\/span> chunkBase64 = arrayBufferToBase64(chunk.buffer);\n\n  <span class=\"hljs-keyword\">const<\/span> result = <span class=\"hljs-keyword\">await<\/span> uploadChunkAction({ <span class=\"hljs-attr\">data<\/span>: { uploadId, <span class=\"hljs-attr\">chunkIndex<\/span>: i, totalChunks, chunkBase64, ... } });\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The server function <a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/actions\/upload-chunk.action.ts\"><code>src\/actions\/upload-chunk.action.ts<\/code><\/a> stores each chunk in a <code>Map<\/code> keyed by <code>uploadId<\/code>. When the final chunk arrives, it concatenates all the buffers and calls <code>uploadToCloudinary<\/code>.<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> chunks = chunkBuffers.get(uploadId)!;\nchunks&#91;chunkIndex] = Buffer.from(chunkBase64, <span class=\"hljs-string\">\"base64\"<\/span>);\n\n<span class=\"hljs-keyword\">if<\/span> (received &lt; totalChunks) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">null<\/span>; <span class=\"hljs-comment\">\/\/ still waiting<\/span>\n\n<span class=\"hljs-keyword\">const<\/span> fileBuffer = Buffer.concat(chunks <span class=\"hljs-keyword\">as<\/span> Buffer&#91;]);\n<span class=\"hljs-keyword\">return<\/span> uploadToCloudinary(fileBuffer, filename, metadata);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The client drives a progress bar as chunks complete \u2014 real progress, not a fake animation.<\/p>\n<blockquote>\n<p><a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/actions\/upload-chunk.action.ts\">View the upload chunk action on GitHub<\/a>.<\/p>\n<\/blockquote>\n<h3>Client-Side Validation (Best Practice)<\/h3>\n<p>Before a single byte leaves the browser, the app validates the file:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">validateImageFile<\/span>(<span class=\"hljs-params\">file: File<\/span>): <span class=\"hljs-title\">string<\/span> | <span class=\"hljs-title\">null<\/span> <\/span>{\n  <span class=\"hljs-keyword\">if<\/span> (!ALLOWED_TYPES.includes(file.type)) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">\"Only JPEG, PNG, WebP or GIF images are allowed.\"<\/span>;\n  <span class=\"hljs-keyword\">if<\/span> (file.size &gt; MAX_FILE_SIZE) <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">`File must be under <span class=\"hljs-subst\">${MAX_FILE_SIZE <span class=\"hljs-regexp\">\/ 1024 \/<\/span> <span class=\"hljs-number\">1024<\/span>}<\/span> MB.`<\/span>;\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-literal\">null<\/span>;\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>This prevents bandwidth waste and gives instant feedback. It\u2019s never a substitute for server-side validation, but is the right first line of defense.<\/p>\n<blockquote>\n<p><a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/lib\/utils.ts\">View validation utils on GitHub<\/a>.<\/p>\n<\/blockquote>\n<h2>Step 7: The Transformation Pipeline<\/h2>\n<p>When the assembled buffer hits <code>uploadToCloudinary<\/code>, the upload preset takes over. Here\u2019s the full transformation chain that Cloudinary applies before storing the image:<\/p>\n<pre class=\"js-syntax-highlighted\"><code>User file \u2192 Resize (c_fill, 800\u00d7800) \u2192 Face crop (g_auto:faces) \u2192 Watermark \u2192 f_webp, q_auto \u2192 Stored\n<\/code><\/pre>\n<p>The app also applies transformations at <strong>delivery time<\/strong> using Cloudinary URLs built in <a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/lib\/cloudinary-url.ts\"><code>src\/lib\/cloudinary-url.ts<\/code><\/a>.<\/p>\n<p>For the gallery grid, each card requests a fresh crop:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getGalleryUrl<\/span>(<span class=\"hljs-params\">publicId: string, fallback: string<\/span>): <span class=\"hljs-title\">string<\/span> <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">`<span class=\"hljs-subst\">${BASE}<\/span>\/f_auto,q_auto,c_fill,g_auto:faces,w_800,h_800\/<span class=\"hljs-subst\">${publicId}<\/span>`<\/span>;\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>For the lightbox ambient background, the app requests a tiny 80px copy with heavy Cloudinary-side blur and a saturation boost:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">getAmbientUrl<\/span>(<span class=\"hljs-params\">publicId: string, fallback: string<\/span>): <span class=\"hljs-title\">string<\/span> <\/span>{\n  <span class=\"hljs-keyword\">return<\/span> <span class=\"hljs-string\">`<span class=\"hljs-subst\">${BASE}<\/span>\/f_jpg,q_1,w_80,e_blur:1500,e_saturation:50\/<span class=\"hljs-subst\">${publicId}<\/span>`<\/span>;\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>This ~1 KB image is stretched behind the sharp photo in the lightbox. Combined with a CSS radial vignette, it creates the ambient \u201ccolour glow\u201d effect that matches the photo\u2019s own palette \u2014 the same technique Apple Music uses for album art. Because Cloudinary does the blur server-side, the browser never has to process a large image to achieve the effect.<\/p>\n<blockquote>\n<p><a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/lib\/cloudinary-url.ts\">View all delivery URL helpers on GitHub<\/a>.<\/p>\n<\/blockquote>\n<h2>Step 8: WebPurify AI Moderation<\/h2>\n<p>Because you set <strong>WebPurify<\/strong> as the moderation provider in your upload preset, every upload is automatically scanned. Cloudinary sends the result back either via webhook (when you have a public URL configured) or via the Admin API.<\/p>\n<p>The app supports both modes:<\/p>\n<p><strong>Polling (development \/ demo):<\/strong> The moderation page has a \u201cCheck status\u201d button. Clicking it calls <code>refreshModerationAction<\/code>, which polls the Cloudinary Admin API:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> resource = <span class=\"hljs-keyword\">await<\/span> cloudinary.api.resource(publicId, { <span class=\"hljs-attr\">moderation<\/span>: <span class=\"hljs-literal\">true<\/span> });\n<span class=\"hljs-keyword\">const<\/span> entry = resource.moderation.find(<span class=\"hljs-function\">(<span class=\"hljs-params\">m<\/span>) =&gt;<\/span> m.kind === <span class=\"hljs-string\">\"webpurify\"<\/span>);\n<span class=\"hljs-keyword\">const<\/span> status = entry.status; <span class=\"hljs-comment\">\/\/ \"approved\" | \"rejected\" | \"pending\"<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>If WebPurify has reached a decision, the result is written back to Cloudinary context (<code>pw_status<\/code>, <code>pw_moderation_source<\/code>) and the page refreshes automatically.<\/p>\n<blockquote>\n<p><a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/actions\/refresh-moderation.action.ts\">View the refresh moderation action on GitHub<\/a>.<\/p>\n<\/blockquote>\n<p><strong>Webhook (production):<\/strong> Set <code>CLOUDINARY_WEBHOOK_URL<\/code> to your deployed URL. Cloudinary will POST the moderation result to that endpoint as soon as WebPurify finishes.<\/p>\n<h2>Step 9: The Moderation Queue<\/h2>\n<p>The moderation page (<a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/routes\/moderate.tsx\"><code>src\/routes\/moderate.tsx<\/code><\/a>) loads all photos from Cloudinary and groups them by status.<\/p>\n<p>Each photo row shows:<\/p>\n<ul>\n<li>A thumbnail.<\/li>\n<li>The title, upload date, and file size stats.<\/li>\n<li>A badge showing who moderated it \u2014 <strong>\ud83e\udd16 WebPurify<\/strong> or <strong>\ud83d\udc64 Human<\/strong>.<\/li>\n<li>\n<strong>Approve<\/strong>, <strong>Reject<\/strong>, and <strong>Reset<\/strong> buttons.<\/li>\n<\/ul>\n<p>When a moderator clicks Approve or Reject, <code>moderatePhotoAction<\/code> is called:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">return<\/span> updatePhotoStatusOnCloudinary(data.publicId, data.status, <span class=\"hljs-string\">\"human\"<\/span>);\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The <code>&quot;human&quot;<\/code> source overwrites whatever WebPurify set. This means the moderation queue is always the source of truth \u2014 you can correct the AI whenever it makes a mistake.<\/p>\n<blockquote>\n<p><a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/actions\/moderate.action.ts\">View the moderate action on GitHub<\/a>.<\/p>\n<\/blockquote>\n<h2>Step 10: The Gallery<\/h2>\n<p>The gallery page (<a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/routes\/gallery.tsx\"><code>src\/routes\/gallery.tsx<\/code><\/a>) only shows approved photos:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> Route = createFileRoute(<span class=\"hljs-string\">\"\/gallery\"<\/span>)({\n  <span class=\"hljs-attr\">loader<\/span>: <span class=\"hljs-function\"><span class=\"hljs-params\">()<\/span> =&gt;<\/span> getApprovedPhotosAction(),\n  <span class=\"hljs-attr\">component<\/span>: GalleryPage,\n});\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p><code>getApprovedPhotosAction<\/code> fetches all photos from Cloudinary and filters by <code>status === &quot;approved&quot;<\/code>. Because the data comes from Cloudinary\u2019s API on every page load, the gallery is always consistent with the current moderation state \u2014 no cache invalidation required.<\/p>\n<p>Each photo card (<a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/components\/photo-card.tsx\"><code>src\/components\/photo-card.tsx<\/code><\/a>) shows:<\/p>\n<ul>\n<li>The processed image from Cloudinary<\/li>\n<li>A coloured status pill (green for approved, amber for pending, red for rejected)<\/li>\n<li>A moderation source badge (\ud83e\udd16 WebPurify or \ud83d\udc64 Human)<\/li>\n<li>Cloudinary transformation chips that slide in on hover, showing exactly what was applied<\/li>\n<\/ul>\n<h2>Step 11: The Lightbox<\/h2>\n<p>Clicking any photo opens a lightbox (<a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/src\/components\/photo-lightbox.tsx\"><code>src\/components\/photo-lightbox.tsx<\/code><\/a>) with two sections in the left panel:<\/p>\n<p><strong>The image section<\/strong> \u2014 a rounded container with the ambient glow background (the Cloudinary <code>e_blur:1500,e_saturation:50<\/code> trick), a radial vignette overlay, and the sharp photo floating on top.<\/p>\n<p><strong>The tags row<\/strong> \u2014 status, moderation source, and Cloudinary badge displayed cleanly below the image with proper breathing room.<\/p>\n<p>The right panel shows transformation stats (dimensions, file size, savings percentage), the full list of Cloudinary transforms applied, and a link to the moderation queue.<\/p>\n<h2>Step 12: Deploy to Vercel With Nitro<\/h2>\n<p>TanStack Start deploys to Vercel via <a href=\"https:\/\/nitro.build\/\">Nitro<\/a>, which handles the serverless adapter automatically. First, install it:<\/p>\n<pre class=\"js-syntax-highlighted\"><span><code class=\"hljs shcb-wrap-lines\">npm install nitro\n<\/code><\/span><\/pre>\n<p>Then add it to your Vite config (<a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\/blob\/main\/vite.config.ts\"><code>vite.config.ts<\/code><\/a>):<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-14\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript shcb-wrap-lines\"><span class=\"hljs-keyword\">import<\/span> { nitro } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'nitro\/vite'<\/span>\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">default<\/span> defineConfig({\n  <span class=\"hljs-attr\">plugins<\/span>: &#91;tanstackStart(), nitro(), viteReact()],\n})\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-14\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>That is it. Nitro detects that the build target is Vercel and generates the correct serverless function output in <code>.output\/<\/code>. No <code>vercel.json<\/code>, no custom API routes, no adapter configuration needed.<\/p>\n<p>Push to GitHub, import to Vercel, add your environment variables in the Vercel project settings, and deploy.<\/p>\n<h2>The Full Flow, End to End<\/h2>\n<pre class=\"js-syntax-highlighted\"><code>User picks a file\n  \u2192 Client validates type and size immediately\n  \u2192 File split into 1 MB chunks\n  \u2192 Chunks sent one at a time to the TanStack Start server function\n  \u2192 Server assembles chunks into a Buffer\n  \u2192 Buffer uploaded to Cloudinary with the upload preset\n  \u2192 Cloudinary applies: resize \u2192 face crop \u2192 watermark \u2192 WebP conversion\n  \u2192 Cloudinary stores the processed image with pw_* context fields\n  \u2192 WebPurify scans the image in the background (async)\n  \u2192 Photo appears in moderation queue with status &quot;pending&quot;\n  \u2192 Moderator clicks &quot;Check status&quot; or webhook fires\n  \u2192 WebPurify result written back to pw_status via add_context\n  \u2192 Moderator can override with Approve\/Reject\n  \u2192 Approved photo appears in the public gallery\n<\/code><\/pre>\n<h2>What Makes This Approach Powerful<\/h2>\n<ul>\n<li>\n<p><strong>Incoming transformations are a one-time cost.<\/strong> The resize, face crop, watermark, and format conversion happen exactly once \u2014 at upload. Every delivery after that is just serving a cached, already-processed file. You never pay for per-request transformations.<\/p>\n<\/li>\n<li>\n<p><strong>Cloudinary as a database removes infrastructure complexity.<\/strong> <code>resources_by_tag<\/code> is your SELECT. <code>add_context<\/code> is your UPDATE. No database provisioning, connection pooling, or migration files. This isn\u2019t the right choice for every project, but for a demo or small community app, it\u2019s reliable.<\/p>\n<\/li>\n<li>\n<p><strong>WebPurify runs without blocking the upload.<\/strong> The photo is stored and available to moderators immediately. The AI result arrives asynchronously. You\u2019ll always have the manual override as a safety net.<\/p>\n<\/li>\n<li>\n<p><strong>The ambient glow in the lightbox is a 1 KB Cloudinary delivery URL.<\/strong> Instead of running a large image through CSS <code>blur()<\/code> in the browser (GPU-intensive, causes layout reflows), you request a tiny preblurred, presaturated version from Cloudinary. The browser just stretches one small image.<\/p>\n<\/li>\n<\/ul>\n<h2>Where to Go Next<\/h2>\n<ul>\n<li>Add a webhook endpoint to receive WebPurify results automatically instead of polling.<\/li>\n<li>Use Cloudinary\u2019s <code>resources_by_tag<\/code> cursor for pagination when the photo count grows.<\/li>\n<li>Add a <code>pw_uploader_email<\/code> context field to track who submitted each photo.<\/li>\n<li>Explore Cloudinary\u2019s AI tagging (<code>auto_tagging<\/code>) to add searchable labels to every photo automatically.<\/li>\n<\/ul>\n<p>Ready to start building with Cloudinary? <a href=\"https:\/\/cloudinary.com\/users\/register_free\">Sign up<\/a> for an account today (it\u2019s free!).<\/p>\n<ul>\n<li>\n<strong>Live demo:<\/strong> <a href=\"https:\/\/tanstack-start-community-photo-wall.vercel.app\/\">https:\/\/tanstack-start-community-photo-wall.vercel.app\/<\/a>\n<\/li>\n<li>\n<strong>Source code:<\/strong> <a href=\"https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall\">https:\/\/github.com\/musebe\/tanstack-start-community-photo-wall<\/a>\n<\/li>\n<\/ul>\n<style>\n    .faqs {padding: 30px 60px; margin-top: 40px; background: var(--color-background-offset); border-radius: 20px;}\n    #frequently_asked_questions {margin-bottom: 20px;}\n    .question {margin-bottom: 20px;}\n<\/style>\n<div class=\"faqs\">\n<h2>Frequently Asked Questions<\/h2>\n<div class=\"question\">\n<p><b>How can I build a community photo app without a database?<\/b><\/p>\nYou can use Cloudinary&#8217;s context fields to store custom metadata directly on each image asset. By saving key-value pairs like titles, IDs, and moderation statuses during the upload phase, you can query and update your gallery&#8217;s data directly through the Cloudinary Admin API.\n<\/div>\n<div class=\"question\">\n<p><b>What is the benefit of applying transformations at the upload stage?<\/b><\/p>\nApplying incoming transformations during upload ensures that images are processed before they are saved to your storage. This is a one-time operation that optimizes quality, applies watermarks, and crops photos permanently, which eliminates ongoing computing costs during delivery.\n<\/div>\n<div class=\"question\">\n<p><b>How does WebPurify AI moderation work with Cloudinary?<\/b><\/p>\nWebPurify is an integrated add-on that automatically scans images for inappropriate content as they are uploaded. The resulting approval status is saved in the asset&#8217;s metadata, allowing your app to display approved images immediately while keeping flagged photos in a moderation queue.\n<\/div>\n<div class=\"question\">\n<p><b>Why should I upload large files using a chunked approach?<\/b><\/p>\nSplitting large files into smaller chunks on the client side prevents browser timeouts and memory spikes during the upload process. The chunks are sent sequentially to your server and assembled into a complete file buffer before upload, providing a reliable progress indicator.\n<\/div>\n<div class=\"question\">\n<p><b>How is the ambient glow background effect achieved efficiently?<\/b><\/p>\nInstead of performing heavy CSS blur operations in the user&#8217;s browser, you can request a tiny, pre-blurred, and saturated image directly from Cloudinary using a custom delivery URL. The browser stretches this tiny background image behind the sharp photo, saving device memory and CPU.\n<\/div>\n<\/div>\n<\/div>","protected":false},"excerpt":{"rendered":"","protected":false},"author":87,"featured_media":40179,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_cloudinary_featured_overwrite":false,"footnotes":""},"categories":[1],"tags":[165,203,300],"class_list":["post-40176","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-image-transformation","tag-moderation","tag-user-generated-content"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v25.6 (Yoast SEO v26.9) - https:\/\/yoast.com\/product\/yoast-seo-premium-wordpress\/ -->\n<title>Build a Community Photo Wall with TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations<\/title>\n<meta name=\"description\" content=\"Learn how to build a database-free community photo wall using TanStack Start and Cloudinary. Secure user-generated content with WebPurify AI moderation and automated incoming transformations.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"How to Create a Community Photo Wall With TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations\" \/>\n<meta property=\"og:description\" content=\"Learn how to build a database-free community photo wall using TanStack Start and Cloudinary. Secure user-generated content with WebPurify AI moderation and automated incoming transformations.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation\" \/>\n<meta property=\"og:site_name\" content=\"Cloudinary Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-07-07T14:00:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-07-08T21:15:15+00:00\" \/>\n<meta name=\"author\" content=\"melindapham\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:image\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"NewsArticle\",\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#article\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation\"},\"author\":{\"name\":\"melindapham\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\"},\"headline\":\"How to Create a Community Photo Wall With TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations\",\"datePublished\":\"2026-07-07T14:00:00+00:00\",\"dateModified\":\"2026-07-08T21:15:15+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation\"},\"wordCount\":17,\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA\",\"keywords\":[\"Image Transformation\",\"Moderation\",\"User-Generated Content\"],\"inLanguage\":\"en-US\",\"copyrightYear\":\"2026\",\"copyrightHolder\":{\"@id\":\"https:\/\/cloudinary.com\/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation\",\"url\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation\",\"name\":\"Build a Community Photo Wall with TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#primaryimage\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA\",\"datePublished\":\"2026-07-07T14:00:00+00:00\",\"dateModified\":\"2026-07-08T21:15:15+00:00\",\"description\":\"Learn how to build a database-free community photo wall using TanStack Start and Cloudinary. Secure user-generated content with WebPurify AI moderation and automated incoming transformations.\",\"breadcrumb\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#primaryimage\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA\",\"width\":4000,\"height\":2200},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/cloudinary.com\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"How to Create a Community Photo Wall With TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"name\":\"Cloudinary Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/cloudinary.com\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\",\"name\":\"Cloudinary Blog\",\"url\":\"https:\/\/cloudinary.com\/blog\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA\",\"width\":312,\"height\":60,\"caption\":\"Cloudinary Blog\"},\"image\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/\"}},{\"@type\":\"Person\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\",\"name\":\"melindapham\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"caption\":\"melindapham\"}}]}<\/script>\n<!-- \/ Yoast SEO Premium plugin. -->","yoast_head_json":{"title":"Build a Community Photo Wall with TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations","description":"Learn how to build a database-free community photo wall using TanStack Start and Cloudinary. Secure user-generated content with WebPurify AI moderation and automated incoming transformations.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation","og_locale":"en_US","og_type":"article","og_title":"How to Create a Community Photo Wall With TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations","og_description":"Learn how to build a database-free community photo wall using TanStack Start and Cloudinary. Secure user-generated content with WebPurify AI moderation and automated incoming transformations.","og_url":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation","og_site_name":"Cloudinary Blog","article_published_time":"2026-07-07T14:00:00+00:00","article_modified_time":"2026-07-08T21:15:15+00:00","author":"melindapham","twitter_card":"summary_large_image","twitter_image":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA","schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"NewsArticle","@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#article","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation"},"author":{"name":"melindapham","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9"},"headline":"How to Create a Community Photo Wall With TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations","datePublished":"2026-07-07T14:00:00+00:00","dateModified":"2026-07-08T21:15:15+00:00","mainEntityOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation"},"wordCount":17,"publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA","keywords":["Image Transformation","Moderation","User-Generated Content"],"inLanguage":"en-US","copyrightYear":"2026","copyrightHolder":{"@id":"https:\/\/cloudinary.com\/#organization"}},{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation","url":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation","name":"Build a Community Photo Wall with TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#primaryimage"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA","datePublished":"2026-07-07T14:00:00+00:00","dateModified":"2026-07-08T21:15:15+00:00","description":"Learn how to build a database-free community photo wall using TanStack Start and Cloudinary. Secure user-generated content with WebPurify AI moderation and automated incoming transformations.","breadcrumb":{"@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#primaryimage","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA","width":4000,"height":2200},{"@type":"BreadcrumbList","@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/cloudinary.com\/blog\/"},{"@type":"ListItem","position":2,"name":"How to Create a Community Photo Wall With TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations"}]},{"@type":"WebSite","@id":"https:\/\/cloudinary.com\/blog\/#website","url":"https:\/\/cloudinary.com\/blog\/","name":"Cloudinary Blog","description":"","publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/cloudinary.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/cloudinary.com\/blog\/#organization","name":"Cloudinary Blog","url":"https:\/\/cloudinary.com\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA","width":312,"height":60,"caption":"Cloudinary Blog"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/logo\/image\/"}},{"@type":"Person","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9","name":"melindapham","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","caption":"melindapham"}}]}},"parsely":{"version":"1.1.0","canonical_url":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation","smart_links":{"inbound":0,"outbound":0},"traffic_boost_suggestions_count":0,"meta":{"@context":"https:\/\/schema.org","@type":"NewsArticle","headline":"How to Create a Community Photo Wall With TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations","url":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA&w=150&h=150&crop=1","image":{"@type":"ImageObject","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA"},"articleSection":"Uncategorized","author":[{"@type":"Person","name":"melindapham"}],"creator":["melindapham"],"publisher":{"@type":"Organization","name":"Cloudinary Blog","logo":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/v1649718331\/Web_Assets\/blog\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877\/cloudinary_logo_for_white_bg_1937437aa7_19374666c7_193742f877.png?_i=AA"},"keywords":["image transformation","moderation","user-generated content"],"dateCreated":"2026-07-07T14:00:00Z","datePublished":"2026-07-07T14:00:00Z","dateModified":"2026-07-08T21:15:15Z"},"rendered":"<meta name=\"parsely-title\" content=\"How to Create a Community Photo Wall With TanStack Start, Cloudinary AI Moderation, and Automatic Image Transformations\" \/>\n<meta name=\"parsely-link\" content=\"https:\/\/cloudinary.com\/blog\/community-photo-wall-tanstack-start-cloudinary-ai-moderation\" \/>\n<meta name=\"parsely-type\" content=\"post\" \/>\n<meta name=\"parsely-image-url\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA&w=150&amp;h=150&amp;crop=1\" \/>\n<meta name=\"parsely-pub-date\" content=\"2026-07-07T14:00:00Z\" \/>\n<meta name=\"parsely-section\" content=\"Uncategorized\" \/>\n<meta name=\"parsely-tags\" content=\"image transformation,moderation,user-generated content\" \/>\n<meta name=\"parsely-author\" content=\"melindapham\" \/>","tracker_url":"https:\/\/cdn.parsely.com\/keys\/cloudinary.com\/p.js"},"jetpack_featured_media_url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1783545177\/Web_Assets\/blog\/Blog_Build_Community_Photo_Wall_with_TanStack_Start\/Blog_Build_Community_Photo_Wall_with_TanStack_Start.jpg?_i=AA","_links":{"self":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40176","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/users\/87"}],"replies":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/comments?post=40176"}],"version-history":[{"count":6,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40176\/revisions"}],"predecessor-version":[{"id":40183,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40176\/revisions\/40183"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media\/40179"}],"wp:attachment":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media?parent=40176"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/categories?post=40176"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/tags?post=40176"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}