{"id":40641,"date":"2026-11-04T07:00:00","date_gmt":"2026-11-04T15:00:00","guid":{"rendered":"https:\/\/cloudinary.com\/blog\/?p=40641"},"modified":"2026-10-29T16:32:59","modified_gmt":"2026-10-29T23:32:59","slug":"cloudinary-nextjs-media-portfolio","status":"publish","type":"post","link":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio","title":{"rendered":"Build a Media Portfolio in 30 Minutes with Cloudinary Upload Widget, Next.js App Router, and Server Actions"},"content":{"rendered":"<div class=\"wp-block-cloudinary-markdown \"><p>A portfolio starts with a file picker, but it rarely ends there. Visitors use\ndifferent screens and browsers. Editors need accessible descriptions. A\nA portrait may need a different crop from a gallery card. Videos need a capable\nplayer. Every byte still has to reach the visitor quickly.<\/p>\n<p>This tutorial builds <strong>Media Quest<\/strong>, a playful portfolio studio that handles\nthat complete path. You will upload images and videos directly to Cloudinary,\nread them back in a Next.js Server Component, explore responsive\ntransformations, and let Cloudinary AI Vision generate and persist alt text\nplus captions. A Server Action provides protected AI backfill for older\nimages. The home route is the working product rather than a marketing page.<\/p>\n<p>The key proof is refresh persistence: upload an asset, refresh the browser,\nand the same asset returns from Cloudinary Search API. This is not a local\narray dressed up as a gallery.<\/p>\n<ul>\n<li>\n<a href=\"https:\/\/github.com\/musebe\/cloudinary-media-portfolio\">Browse the GitHub repository<\/a>\n<\/li>\n<li>\n<a href=\"https:\/\/cloudinary-media-portfolio.vercel.app\/\">Try the live demo<\/a>\n<\/li>\n<\/ul>\n<h2>What You\u2019ll Build<\/h2>\n<p>Media Quest includes four connected stations:<\/p>\n<ul>\n<li>\n<strong>Upload Mission<\/strong> opens Cloudinary Upload Widget with local file, camera,\nand remote URL sources, or generates a managed image through Cloudinary\u2019s\nmulti-model Image Generation API.<\/li>\n<li>\n<strong>Portfolio Vault<\/strong> reads persisted images and videos from one Cloudinary\nasset folder, then adds search and resource-type filters.<\/li>\n<li>\n<strong>Transformation Lab<\/strong> derives several visual treatments from one original,\nincluding automatic-gravity cropping and opt-in generative fill.<\/li>\n<li>\n<strong>Delivery Board<\/strong> explains responsive <code>srcset<\/code>, automatic format and\nquality, the delivery hostname, and CDN caching.<\/li>\n<\/ul>\n<p>Open any asset to inspect its dimensions and delivery URL. The detail sheet\nalso displays a read-only AI story. Cloudinary AI Vision creates distinct alt\ntext and a portfolio caption, while the app validates and stores both as\nCloudinary context metadata. You do not write either field manually.<\/p>\n<h2>Why Cloudinary and the App Router Fit Together<\/h2>\n<p>The browser can select files and shows immediate feedback. It\nshouldn\u2019t receive an API secret. The server is good at authenticated Search\nAPI queries and controlled metadata writes, and shouldn\u2019t become an\nunnecessary media proxy.<\/p>\n<p>This split gives each layer one clear job:<\/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\">Browser -&gt; Upload Widget -&gt; Cloudinary\nBounded recent upload -&gt; Analyze API -&gt; alt text + caption -&gt; context\nCloudinary -&gt; Search API -&gt; Server Component -&gt; gallery\nOlder asset -&gt; Server Action -&gt; AI Vision -&gt; Cloudinary metadata\n<span class=\"hljs-keyword\">Public<\/span> ID -&gt; transformation URL -&gt; CDN -&gt; visitor\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>Cloudinary\u2019s Upload Widget supports direct uploads while an unsigned upload\npreset supplies account-side restrictions. The\n<a href=\"https:\/\/cloudinary.com\/documentation\/upload_widget\">Cloudinary Upload Widget documentation<\/a>\nand <a href=\"https:\/\/cloudinary.com\/documentation\/upload_presets\">upload preset documentation<\/a>\ncover the available sources and preset controls.<\/p>\n<p>Next.js Server Components keep the Admin API credentials out of the client\nbundle. A Server Action lets an authorized editor trigger AI backfill for an\nolder asset without accepting author-written metadata. See the current\n<a href=\"https:\/\/nextjs.org\/docs\/app\/guides\/server-actions\">Next.js Server Actions guide<\/a>\nfor the framework\u2019s security and validation recommendations.<\/p>\n<h2>Prerequisites<\/h2>\n<p>You need:<\/p>\n<ul>\n<li>Node.js 20.9 or newer<\/li>\n<li>A Cloudinary account<\/li>\n<li>The Cloudinary AI Vision add-on for automatic image descriptions<\/li>\n<li>The Cloudinary Image Generation add-on for the optional generation path<\/li>\n<li>Basic React and TypeScript familiarity<\/li>\n<li>About 30 minutes for the core path<\/li>\n<\/ul>\n<p>Clone the project, install the packages, and create your local environment\nfile:<\/p>\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\">npm<\/span> <span class=\"hljs-selector-tag\">install<\/span>\n<span class=\"hljs-selector-tag\">cp<\/span> <span class=\"hljs-selector-class\">.env<\/span><span class=\"hljs-selector-class\">.example<\/span> <span class=\"hljs-selector-class\">.env<\/span><span class=\"hljs-selector-class\">.local<\/span>\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<p>The project uses Next.js 16, React 19, <code>next-cloudinary<\/code>, the Cloudinary Node\nSDK, Zod, Tailwind CSS, and shadcn\/ui. Its playful TweakCN theme uses\nself-hosted fonts, rounded cards, bright yellow action surfaces, and purple\nprogress accents.<\/p>\n<h2>Minute 0 to 5: Create a Restricted Upload Preset<\/h2>\n<p>Open the Cloudinary Console, choose <strong>Settings<\/strong>, open <strong>Upload<\/strong>, and add an\nupload preset.<\/p>\n<p>Use these values:<\/p>\n<ul>\n<li>Preset name: <code>media_portfolio_unsigned<\/code>\n<\/li>\n<li>Signing mode: <strong>Unsigned<\/strong>\n<\/li>\n<li>Asset folder: <code>media-portfolio<\/code>\n<\/li>\n<li>Allowed formats: <code>jpg,jpeg,png,webp,avif,gif,mp4,webm,mov<\/code>\n<\/li>\n<\/ul>\n<p>Set account-level file-size restrictions that match your product. The demo\nalso asks the widget to allow at most eight queued files, 12 MB images, and\n80 MB videos. Browser options improve the experience, but the preset is the\nenforcement boundary.<\/p>\n<p>Add the public upload values to <code>.env.local<\/code>:<\/p>\n<pre class=\"js-syntax-highlighted\"><span><code class=\"hljs shcb-wrap-lines\">NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your_cloud_name\nNEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=media_portfolio_unsigned\n<\/code><\/span><\/pre>\n<p>These values may reach the browser. An unsigned preset is not a secret, so it\nmust be narrow. Never add <code>NEXT_PUBLIC_<\/code> to an API secret.<\/p>\n<h2>Minute 5 to 10: Upload Without Proxying Media Through Next.js<\/h2>\n<p><code>UploadMission<\/code> is a Client Component because the widget needs browser events.\nThe essential integration is compact:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml shcb-wrap-lines\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">CldUploadWidget<\/span>\n  <span class=\"hljs-attr\">uploadPreset<\/span>=<span class=\"hljs-string\">{uploadPreset}<\/span>\n  <span class=\"hljs-attr\">config<\/span>=<span class=\"hljs-string\">{{<\/span> <span class=\"hljs-attr\">cloud:<\/span> { <span class=\"hljs-attr\">cloudName<\/span> } }}\n  <span class=\"hljs-attr\">options<\/span>=<span class=\"hljs-string\">{{<\/span>\n    <span class=\"hljs-attr\">sources:<\/span> &#91;\"<span class=\"hljs-attr\">local<\/span>\", \"<span class=\"hljs-attr\">camera<\/span>\", \"<span class=\"hljs-attr\">url<\/span>\"],\n    <span class=\"hljs-attr\">multiple:<\/span> <span class=\"hljs-attr\">true<\/span>,\n    <span class=\"hljs-attr\">maxFiles:<\/span> <span class=\"hljs-attr\">8<\/span>,\n    <span class=\"hljs-attr\">resourceType:<\/span> \"<span class=\"hljs-attr\">auto<\/span>\",\n  }}\n  <span class=\"hljs-attr\">onSuccess<\/span>=<span class=\"hljs-string\">{(result)<\/span> =&gt;<\/span> addOptimisticAsset(result)}\n  onQueuesEnd={(_, { widget }) =&gt; {\n    widget.close();\n    router.refresh();\n  }}\n&gt;\n  {({ open }) =&gt; (\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">Button<\/span> <span class=\"hljs-attr\">onClick<\/span>=<span class=\"hljs-string\">{()<\/span> =&gt;<\/span> open()}&gt;Upload media<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">Button<\/span>&gt;<\/span>\n  )}\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">CldUploadWidget<\/span>&gt;<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n<p>The actual component performs result type checks, reports status through an\n<code>aria-live<\/code> region, and adds a temporary optimistic card. When the queue ends,\n<code>router.refresh()<\/code> asks the Server Component for Cloudinary\u2019s persisted view.\nFor images, it also forwards the immutable asset ID, public ID, and version to\n<code>\/api\/describe<\/code>. The server reads the asset back and requires the exact\nportfolio folder, preset tag, resource type, matching creation version, and a\n15-minute window before AI Vision can spend quota.\nThe app detects Cloudinary folder mode. The widget sends\n<code>asset_folder=media-portfolio<\/code> for dynamic folders or\n<code>folder=media-portfolio<\/code> for a legacy fixed-folder environment. The unsigned\npreset defines the same destination as the authoritative value.<\/p>\n<blockquote>\n<p>Read the full implementation in\n<a href=\"..\/src\/components\/upload-mission.tsx\"><code>src\/components\/upload-mission.tsx<\/code><\/a>.<\/p>\n<\/blockquote>\n<p>This path is efficient because the file travels from the visitor to\nCloudinary. Your Next.js function does not buffer a large image or video.<\/p>\n<h2>Power-Up: Generate a Managed Portfolio Image<\/h2>\n<p>The Upload Mission also has a <strong>Generate<\/strong> tab. It follows the same managed\nasset pattern used in the companion image-generation project: The browser\nsends a prompt, model family, tier, and portfolio edit token to a Node.js Route\nHandler. The Cloudinary API key and secret remain on the server.<\/p>\n<p>Enable the\n<a href=\"https:\/\/cloudinary.com\/documentation\/image_generation_addon\">Cloudinary Image Generation add-on<\/a>,\nthen create a signed preset named <code>media_portfolio_generations<\/code>. Set its\ndynamic <strong>Asset folder<\/strong> or legacy <strong>Folder<\/strong> to the same <code>media-portfolio<\/code>\nvalue used by the unsigned Upload Widget preset.<\/p>\n<p>Add the preset name to <code>.env.local<\/code>:<\/p>\n<pre class=\"js-syntax-highlighted\"><span><code class=\"hljs shcb-wrap-lines\">CLOUDINARY_IMAGE_GENERATION_PRESET=media_portfolio_generations\n<\/code><\/span><\/pre>\n<p>The route maps five allowlisted model families and standard or premium tiers\nto current model IDs. It calls the account-scoped <code>text_to_image<\/code> endpoint with\nHTTP Basic authentication:<\/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> response = <span class=\"hljs-keyword\">await<\/span> fetch(\n  <span class=\"hljs-string\">`https:\/\/api.cloudinary.com\/v2\/generate\/<span class=\"hljs-subst\">${cloudName}<\/span>\/text_to_image`<\/span>,\n  {\n    <span class=\"hljs-attr\">method<\/span>: <span class=\"hljs-string\">\"POST\"<\/span>,\n    <span class=\"hljs-attr\">headers<\/span>: {\n      <span class=\"hljs-attr\">Authorization<\/span>: <span class=\"hljs-string\">`Basic <span class=\"hljs-subst\">${authorization}<\/span>`<\/span>,\n      <span class=\"hljs-string\">\"Content-Type\"<\/span>: <span class=\"hljs-string\">\"application\/json\"<\/span>,\n    },\n    <span class=\"hljs-attr\">body<\/span>: <span class=\"hljs-built_in\">JSON<\/span>.stringify({\n      prompt,\n      <span class=\"hljs-attr\">model<\/span>: { <span class=\"hljs-attr\">id<\/span>: modelId },\n      <span class=\"hljs-attr\">target<\/span>: {\n        <span class=\"hljs-attr\">target_type<\/span>: <span class=\"hljs-string\">\"managed_asset\"<\/span>,\n        <span class=\"hljs-attr\">public_id<\/span>: generatedId,\n        <span class=\"hljs-attr\">upload_preset<\/span>: generationPreset,\n      },\n    }),\n  },\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 request has a 120-second timeout because image generation takes longer\nthan a metadata write. The edit token protects add-on credits in this\nsingle-user demo. A public multi-user product should add authentication,\ndurable rate limiting, ownership checks, and account quota monitoring.<\/p>\n<p>Folder consistency is verified rather than assumed. Before spending quota,\nthe route reads the generation preset through Admin API and confirms it is\nsigned and targets <code>media-portfolio<\/code> through the mode-appropriate folder\nfield. After generation, it reads the immutable asset ID and verifies the\nstored folder again. The gallery likewise chooses <code>asset_folder<\/code> for dynamic\nfolders or <code>folder<\/code> for fixed folders, so both uploads and generations remain\nvisible after refresh. The protected generation route then asks AI Vision for\nalt text and a caption before returning the generated asset.<\/p>\n<h2>Minute 10 to 15: Read the Portfolio on the Server<\/h2>\n<p>Add the server-only values to <code>.env.local<\/code>:<\/p>\n<pre class=\"js-syntax-highlighted\"><span><code class=\"hljs shcb-wrap-lines\">CLOUDINARY_API_KEY=your_api_key\nCLOUDINARY_API_SECRET=your_api_secret\nCLOUDINARY_PORTFOLIO_FOLDER=media-portfolio\nCLOUDINARY_IMAGE_GENERATION_PRESET=media_portfolio_generations\nPORTFOLIO_ADMIN_TOKEN=create_a_long_random_token\n<\/code><\/span><\/pre>\n<p>The Cloudinary module begins with <code>import &quot;server-only&quot;<\/code>. That guard makes an\naccidental client import fail during development instead of leaking privileged\ncode into the browser.<\/p>\n<p>The home page is a Server Component:<\/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\">export<\/span> <span class=\"hljs-keyword\">default<\/span> <span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">Home<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n  <span class=\"hljs-keyword\">await<\/span> connection();\n  <span class=\"hljs-keyword\">const<\/span> snapshot = <span class=\"hljs-keyword\">await<\/span> getPortfolioSnapshot();\n\n  <span class=\"hljs-keyword\">return<\/span> (\n    <span class=\"xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">main<\/span>&gt;<\/span>\n      <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">MediaStudio<\/span> <span class=\"hljs-attr\">snapshot<\/span>=<span class=\"hljs-string\">{snapshot}<\/span> \/&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">main<\/span>&gt;<\/span><\/span>\n  );\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><code>connection()<\/code> tells Next.js that the result belongs to request time. Live\nCloudinary data is not frozen into a production build.<\/p>\n<p><code>getPortfolioSnapshot()<\/code> configures the Node SDK and queries Search API:<\/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> folderMode = <span class=\"hljs-keyword\">await<\/span> resolveFolderMode();\n<span class=\"hljs-keyword\">const<\/span> folderField = folderMode === <span class=\"hljs-string\">\"fixed\"<\/span> ? <span class=\"hljs-string\">\"folder\"<\/span> : <span class=\"hljs-string\">\"asset_folder\"<\/span>;\n\n<span class=\"hljs-keyword\">const<\/span> result = <span class=\"hljs-keyword\">await<\/span> cloudinary.search\n  .expression(<span class=\"hljs-string\">`<span class=\"hljs-subst\">${folderField}<\/span>=\"<span class=\"hljs-subst\">${PORTFOLIO_FOLDER}<\/span>\"`<\/span>)\n  .sort_by(<span class=\"hljs-string\">\"created_at\"<\/span>, <span class=\"hljs-string\">\"desc\"<\/span>)\n  .max_results(<span class=\"hljs-number\">40<\/span>)\n  .with_field(<span class=\"hljs-string\">\"context\"<\/span>)\n  .with_field(<span class=\"hljs-string\">\"tags\"<\/span>)\n  .execute();\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 function treats the SDK response as external data, checks field types, and\nreturns a smaller <code>PortfolioAsset<\/code> shape. This prevents rendering code from\ndepending on every detail of the Admin API response. The current\n<a href=\"https:\/\/cloudinary.com\/documentation\/search_method\">Cloudinary Search API reference<\/a>\nexplains expressions, sorting, fields, and pagination.<\/p>\n<p>When credentials are absent, Media Quest enters a labelled demo mode with\npublic assets from Cloudinary\u2019s <code>demo<\/code> cloud. Upload and metadata setup states\nremain visible. It never claims those samples are the user\u2019s persistent data.<\/p>\n<h2>Minute 15 to 20: Transform One Original Into a Responsive System<\/h2>\n<p>The portfolio doesn\u2019t create and upload five copies of every image. It keeps\none original and expresses each layout need as a transformation.<\/p>\n<p>The gallery cards use <code>CldImage<\/code>, a width and height, responsive sizing hints,\nand accessible alt text. The component generates a responsive <code>srcset<\/code> while\nCloudinary applies automatic format and quality delivery. <code>f_auto<\/code> can choose\nan efficient browser-supported format, while <code>q_auto<\/code> balances visual quality\nand bytes. Cloudinary documents both in its\n<a href=\"https:\/\/cloudinary.com\/documentation\/image_optimization\">image optimization guide<\/a>.<\/p>\n<p>The Transformation Lab makes the URL model visible:<\/p>\n<ol>\n<li>\n<strong>Fit<\/strong> preserves the complete frame inside a landscape box.<\/li>\n<li>\n<strong>Smart crop<\/strong> uses fill cropping with automatic gravity to protect the\nvisually relevant region.<\/li>\n<li>\n<strong>Portrait<\/strong> first identifies the subject, then creates an editorial\nvertical composition.<\/li>\n<li>\n<strong>Grayscale<\/strong> changes the treatment without creating another stored source.<\/li>\n<li>\n<strong>Generative fill<\/strong> expands the image to a new aspect ratio when the user\nexplicitly enables it.<\/li>\n<\/ol>\n<p>Generative transformations can add processing cost and may require human\nreview. Media Quest labels that choice and leaves it off by default. This is a\nmore honest product pattern than silently spending transformation credits.<\/p>\n<p>Cloudinary transformations are URL instructions. Different transformation\nURLs become separately cacheable derived assets, while the original remains\navailable for future designs. Use the\n<a href=\"https:\/\/cloudinary.com\/documentation\/transformation_reference\">transformation reference<\/a>\nwhen adding effects beyond this lab.<\/p>\n<h2>Minute 20 to 25: Generate Alt Text and Captions With AI Vision<\/h2>\n<p>Alt text and captions have related but different jobs. Alt text communicates\nvisible, relevant content when the image is unavailable. A portfolio caption\ncan add editorial framing for every visitor. Reusing one generic sentence for\nboth weakens that distinction.<\/p>\n<p>Register for the\n<a href=\"https:\/\/cloudinary.com\/documentation\/cloudinary_ai_vision_addon\">Cloudinary AI Vision add-on<\/a>.\nThe server calls AI Vision General through the\n<a href=\"https:\/\/cloudinary.com\/documentation\/analyze_api_guide\">Analyze API<\/a> with the\nimmutable Cloudinary asset ID. One prompt includes a JSON Schema requiring\nseparate <code>alt<\/code> and <code>caption<\/code> strings:<\/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> response = <span class=\"hljs-keyword\">await<\/span> fetch(\n  <span class=\"hljs-string\">`<span class=\"hljs-subst\">${analysisBaseUrl}<\/span>\/analyze\/ai_vision_general`<\/span>,\n  {\n    <span class=\"hljs-attr\">method<\/span>: <span class=\"hljs-string\">\"POST\"<\/span>,\n    <span class=\"hljs-attr\">headers<\/span>: {\n      <span class=\"hljs-attr\">Authorization<\/span>: <span class=\"hljs-string\">`Basic <span class=\"hljs-subst\">${authorization}<\/span>`<\/span>,\n      <span class=\"hljs-string\">\"Content-Type\"<\/span>: <span class=\"hljs-string\">\"application\/json\"<\/span>,\n    },\n    <span class=\"hljs-attr\">body<\/span>: <span class=\"hljs-built_in\">JSON<\/span>.stringify({\n      <span class=\"hljs-attr\">source<\/span>: { <span class=\"hljs-attr\">asset_id<\/span>: asset.assetId },\n      <span class=\"hljs-attr\">prompts<\/span>: &#91;descriptionPrompt],\n    }),\n  },\n);\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>Structured output arrives as a JSON string inside\n<code>data.analysis.responses[0].value<\/code>. The app parses it, validates both strings\nwith Zod, and rejects malformed or oversized output before writing anything.\nIt then stores the result with the asset:<\/p>\n<pre class=\"js-syntax-highlighted\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-wrap-lines\"><span class=\"hljs-selector-tag\">await<\/span> <span class=\"hljs-selector-tag\">cloudinary<\/span><span class=\"hljs-selector-class\">.uploader<\/span><span class=\"hljs-selector-class\">.explicit<\/span>(<span class=\"hljs-selector-tag\">asset<\/span><span class=\"hljs-selector-class\">.publicId<\/span>, {\n  <span class=\"hljs-attribute\">type<\/span>: <span class=\"hljs-string\">\"upload\"<\/span>,\n  resource_type: <span class=\"hljs-string\">\"image\"<\/span>,\n  context: {\n    alt,\n    caption,\n    ai_description_source: <span class=\"hljs-string\">\"cloudinary_ai_vision\"<\/span>,\n    ai_description_model: modelVersion,\n  },\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\">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>The helper reads the immutable asset ID back and compares the persisted\nmetadata with the generated values. This makes Cloudinary, rather than React\nstate, the source of truth.<\/p>\n<p>Two automatic triggers cover new images:<\/p>\n<ol>\n<li>Upload Widget returns the immutable asset ID, public ID, and version. The\ndescription route verifies those values against a recent, tagged image in\nthe exact portfolio folder before calling AI Vision.<\/li>\n<li>Image Generation already runs behind the edit token, so its route calls AI\nVision immediately after folder verification.<\/li>\n<\/ol>\n<p>An older image without metadata displays one <strong>Generate story with Cloudinary\nAI<\/strong> action. Its Server Action accepts only <code>assetId<\/code> and the edit token. It\ndoes not accept manually written alt text or captions.<\/p>\n<blockquote>\n<p>Read the full workflow in\n<a href=\"..\/src\/lib\/cloudinary-description.ts\"><code>src\/lib\/cloudinary-description.ts<\/code><\/a>,\n<a href=\"..\/src\/app\/api\/describe\/route.ts\"><code>src\/app\/api\/describe\/route.ts<\/code><\/a>, and\n<a href=\"..\/src\/app\/actions.ts\"><code>src\/app\/actions.ts<\/code><\/a>.<\/p>\n<\/blockquote>\n<p>AI-generated text still needs an editorial review policy. Automation removes\nrepetitive writing, but it does not guarantee Web Content Accessibility\nGuidelines (WCAG) conformance or prevent every unsupported inference.<\/p>\n<h2>Minute 25 to 30: Inspect Delivery and Verify the Proof<\/h2>\n<p>Start the project:<\/p>\n<pre class=\"js-syntax-highlighted\"><span><code class=\"hljs shcb-wrap-lines\">npm run dev\n<\/code><\/span><\/pre>\n<p>Open <code>http:\/\/localhost:3000<\/code>, upload an image, and watch it appear. Then perform\nthe important test:<\/p>\n<ol>\n<li>Refresh the browser.<\/li>\n<li>Confirm the uploaded asset remains.<\/li>\n<li>Open <strong>Generate<\/strong> and create one standard-tier managed image.<\/li>\n<li>Confirm the response verifies the folder and stores AI metadata.<\/li>\n<li>Open either asset\u2019s detail sheet.<\/li>\n<li>Inspect the read-only AI alt text and portfolio caption.<\/li>\n<li>Refresh again and confirm both fields remain without manual writing.<\/li>\n<li>Open the Delivery Board and inspect the generated URL.<\/li>\n<\/ol>\n<p>The app also exposes <code>\/api\/health<\/code>. It reports readiness booleans and the asset\nfolder without returning credential values.<\/p>\n<p>Run the full local verification before deployment:<\/p>\n<pre class=\"js-syntax-highlighted\"><span><code class=\"hljs shcb-wrap-lines\">npm run check\n<\/code><\/span><\/pre>\n<p>That command runs ESLint, TypeScript, and an optimized production build.<\/p>\n<h2>Where the CDN and Multi-CDN Fit<\/h2>\n<p>A Cloudinary delivery URL normally uses <code>res.cloudinary.com<\/code>. On the first\nrequest for a transformation, Cloudinary produces or retrieves the derived\nasset and delivers it through the configured CDN. Subsequent requests can be\nserved from edge caches close to visitors.<\/p>\n<p>Private CDN distributions and custom delivery hostnames require account\nconfiguration. Cloudinary also offers smart and dynamic multi-CDN capabilities\nfor eligible accounts, generally as enterprise delivery features. They are\nconfigured at the Cloudinary account and delivery layer, not activated by a\nReact state toggle.<\/p>\n<p>Media Quest shows the active delivery hostname and accepts optional\n<code>NEXT_PUBLIC_CLOUDINARY_SECURE_DISTRIBUTION<\/code> and\n<code>NEXT_PUBLIC_CLOUDINARY_PRIVATE_CDN<\/code> values. Only set them after Cloudinary has\nconfigured that distribution for your account. Read\n<a href=\"https:\/\/cloudinary.com\/documentation\/advanced_url_delivery_options\">Cloudinary\u2019s advanced URL delivery options<\/a>\nfor the current requirements.<\/p>\n<p>This separation is powerful: application code asks for the media variant, and\nthe delivery configuration decides how that response reaches the visitor.<\/p>\n<h2>Performance and Accessibility Choices Worth Keeping<\/h2>\n<p>The demo includes several patterns that belong in production work:<\/p>\n<ul>\n<li>Gallery images have intrinsic dimensions, responsive size hints, and lazy\nloading outside the leading content.<\/li>\n<li>The video player is dynamically imported only when a video detail view needs\nit.<\/li>\n<li>Search uses <code>useDeferredValue<\/code>, keeping typing responsive as the gallery\nfilters.<\/li>\n<li>Alt text is stored with the asset instead of scattered through page code.<\/li>\n<li>Loading, empty, error, demo, and incomplete-setup states are distinct.<\/li>\n<li>Upload status uses a polite live region.<\/li>\n<li>Motion is reduced when the visitor requests reduced motion.<\/li>\n<li>Server credentials remain in a module guarded by <code>server-only<\/code>.<\/li>\n<\/ul>\n<p>Performance is a system property. Cloudinary reduces media weight, while\nNext.js controls rendering and JavaScript boundaries. The best result comes\nfrom using both intentionally.<\/p>\n<h2>Ideas for the Next Quest<\/h2>\n<p>Once the core proof works, extend one boundary at a time:<\/p>\n<ul>\n<li>Add signed uploads when users need dynamic, trusted upload parameters.<\/li>\n<li>Add authentication and per-user asset folders.<\/li>\n<li>Add durable per-user limits for Image Generation API credits.<\/li>\n<li>Add durable per-user limits for AI Vision analysis tokens.<\/li>\n<li>Create a webhook-driven moderation or AI-tagging workflow.<\/li>\n<li>Paginate Search API results with <code>next_cursor<\/code>.<\/li>\n<li>Add a comparison panel that records transferred bytes through the browser\nPerformance API.<\/li>\n<li>Generate social cards with a Cloudinary overlay transformation.<\/li>\n<li>Add an authenticated delete flow with confirmation and an audit trail.<\/li>\n<li>Connect a configured private CDN or enterprise multi-CDN account and compare\ncache headers across regions.<\/li>\n<\/ul>\n<p>The most important design principle remains the same: Store the original once,\nkeep privileged operations on the server, and derive delivery-ready media at\nthe edge.<\/p>\n<p>Ready to start building? <a href=\"https:\/\/cloudinary.com\/users\/register_free\">Sign up for a free Cloudinary account<\/a> today and build better visual media experiences.<\/p>\n<\/div>\n\n\n<style>\n.faqs {\n    padding: 30px 60px;\n    margin-top: 40px;\n    background: var(--color-background-offset);\n    border-radius: 20px;\n}\n\n#frequently_asked_questions {\n    margin-bottom: 20px;\n}\n\n.question {\n    margin-bottom: 12px;\n    border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n}\n\n.question summary {\n    position: relative;\n    padding: 18px 40px 18px 0;\n    cursor: pointer;\n    font-weight: 700;\n    list-style: none;\n}\n\n.question summary::-webkit-details-marker {\n    display: none;\n}\n\n.question summary::after {\n    content: \"+\";\n    position: absolute;\n    top: 50%;\n    right: 0;\n    transform: translateY(-50%);\n    font-size: 24px;\n    font-weight: 400;\n    line-height: 1;\n}\n\n.question[open] summary::after {\n    content: \"\u2212\";\n}\n\n.answer {\n    padding: 0 40px 20px 0;\n}\n<\/style>\n\n<div class=\"faqs\">\n\n<h2 id=\"frequently_asked_questions\">Frequently Asked Questions<\/h2>\n\n<details class=\"question\">\n    <summary>Is an unsigned upload preset safe?<\/summary>\n    <div class=\"answer\">It is intentionally public, so safety comes from restriction. Limit formats, file sizes, asset location, and any transformations the preset may apply. Use signed uploads when the server must authorize dynamic parameters or user ownership.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Why use Search API instead of keeping upload results in React state?<\/summary>\n    <div class=\"answer\">React state provides immediate feedback but disappears on refresh. Search API readback proves persistence and lets other sessions see the same Cloudinary source of truth.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Why use both a route handler and a Server Action for AI metadata?<\/summary>\n    <div class=\"answer\">The route handler receives identifiers from a fresh Upload Widget upload, reads the asset back, applies bounded folder, tag, type, version, and age checks, then starts analysis automatically. The Server Action is the protected retry and backfill path for older assets. Both keep the API secret on the server and verify the immutable asset ID and portfolio folder.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Does generated alt text guarantee accessibility?<\/summary>\n    <div class=\"answer\">No. AI Vision accelerates description work and centralizes the result in Cloudinary, but output can omit context or make mistakes. Define review rules for important content and treat decorative images separately with empty alt text where appropriate.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Does <code>f_auto,q_auto<\/code> mean every visitor receives the same file?<\/summary>\n    <div class=\"answer\">No. Cloudinary can negotiate an appropriate format and choose an automatic quality level for the requested transformation. Browser support, content, and delivery configuration influence the result.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Does this app enable Cloudinary multi-CDN?<\/summary>\n    <div class=\"answer\">No client application can enable an account-level CDN product. The app demonstrates CDN-ready transformation URLs and configurable delivery hosts. Cloudinary must enable private CDN or eligible smart and dynamic multi-CDN features for the account.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Can the same approach handle video?<\/summary>\n    <div class=\"answer\">Yes. Upload Widget uses <code>resourceType: \"auto\"<\/code>, the server normalizes image and video results, and video details load Cloudinary Video Player. Production video workflows may add eager transformations, adaptive streaming, captions, and webhook status handling.<\/div>\n<\/details>\n\n<details class=\"question\">\n    <summary>Are Cloudinary-generated images stored with uploads?<\/summary>\n    <div class=\"answer\">Yes. A <code>managed_asset<\/code> generation target stores the generated original in the Cloudinary product environment. This project applies a signed preset and then verifies that its asset folder matches the Upload Widget&#8217;s <code>media-portfolio<\/code> folder.<\/div>\n<\/details>\n\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Frequently Asked Questions Is an unsigned upload preset safe? It is intentionally public, so safety comes from restriction. Limit formats, file sizes, asset location, and any transformations the preset may apply. Use signed uploads when the server must authorize dynamic parameters or user ownership. Why use Search API instead of keeping upload results in React [&hellip;]<\/p>\n","protected":false},"author":87,"featured_media":40643,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_cloudinary_featured_overwrite":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1],"tags":[337,370,165,206,212,373],"class_list":["post-40641","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-accessibility","tag-image","tag-image-transformation","tag-multi-cdn","tag-next-js","tag-upload"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO Premium plugin v28.3 (Yoast SEO v28.3) - https:\/\/yoast.com\/product\/yoast-seo-premium-wordpress\/ -->\n<title>Build a Cloudinary Media Portfolio With Next.js in 30 Minutes<\/title>\n<meta name=\"description\" content=\"Build a responsive Next.js portfolio with Cloudinary Upload Widget, automatic AI alt text and captions, Image Generation, transformations, and CDN delivery.\" \/>\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\/cloudinary-nextjs-media-portfolio\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Build a Media Portfolio in 30 Minutes with Cloudinary Upload Widget, Next.js App Router, and Server Actions\" \/>\n<meta property=\"og:description\" content=\"Build a responsive Next.js portfolio with Cloudinary Upload Widget, automatic AI alt text and captions, Image Generation, transformations, and CDN delivery.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio\" \/>\n<meta property=\"og:site_name\" content=\"Cloudinary Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-11-04T15:00:00+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\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.jpg?_i=AA\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio\"},\"author\":{\"name\":\"melindapham\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#\\\/schema\\\/person\\\/0d5ad601e4c3b5be89245dfb14be42d9\"},\"headline\":\"Build a Media Portfolio in 30 Minutes with Cloudinary Upload Widget, Next.js App Router, and Server Actions\",\"datePublished\":\"2026-11-04T15:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio\"},\"wordCount\":362,\"publisher\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#organization\"},\"image\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1793316738\\\/Web_Assets\\\/blog\\\/Blog_Build_a_Media_Portfolio\\\/Blog_Build_a_Media_Portfolio.jpg?_i=AA\",\"keywords\":[\"Accessibility\",\"Image\",\"Image Transformation\",\"Multi-CDN\",\"Next.js\",\"Upload\"],\"inLanguage\":\"en-US\",\"copyrightYear\":\"2026\",\"copyrightHolder\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio\",\"url\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio\",\"name\":\"Build a Cloudinary Media Portfolio With Next.js in 30 Minutes\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1793316738\\\/Web_Assets\\\/blog\\\/Blog_Build_a_Media_Portfolio\\\/Blog_Build_a_Media_Portfolio.jpg?_i=AA\",\"datePublished\":\"2026-11-04T15:00:00+00:00\",\"description\":\"Build a responsive Next.js portfolio with Cloudinary Upload Widget, automatic AI alt text and captions, Image Generation, transformations, and CDN delivery.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio#primaryimage\",\"url\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1793316738\\\/Web_Assets\\\/blog\\\/Blog_Build_a_Media_Portfolio\\\/Blog_Build_a_Media_Portfolio.jpg?_i=AA\",\"contentUrl\":\"https:\\\/\\\/res.cloudinary.com\\\/cloudinary-marketing\\\/images\\\/f_auto,q_auto\\\/v1793316738\\\/Web_Assets\\\/blog\\\/Blog_Build_a_Media_Portfolio\\\/Blog_Build_a_Media_Portfolio.jpg?_i=AA\",\"width\":4000,\"height\":2200},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/cloudinary.com\\\/blog\\\/cloudinary-nextjs-media-portfolio#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/cloudinary.com\\\/blog\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Build a Media Portfolio in 30 Minutes with Cloudinary Upload Widget, Next.js App Router, and Server Actions\"}]},{\"@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:\\\/\\\/secure.gravatar.com\\\/avatar\\\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g\",\"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 Cloudinary Media Portfolio With Next.js in 30 Minutes","description":"Build a responsive Next.js portfolio with Cloudinary Upload Widget, automatic AI alt text and captions, Image Generation, transformations, and CDN delivery.","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\/cloudinary-nextjs-media-portfolio","og_locale":"en_US","og_type":"article","og_title":"Build a Media Portfolio in 30 Minutes with Cloudinary Upload Widget, Next.js App Router, and Server Actions","og_description":"Build a responsive Next.js portfolio with Cloudinary Upload Widget, automatic AI alt text and captions, Image Generation, transformations, and CDN delivery.","og_url":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio","og_site_name":"Cloudinary Blog","article_published_time":"2026-11-04T15:00:00+00:00","author":"melindapham","twitter_card":"summary_large_image","twitter_image":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.jpg?_i=AA","schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio#article","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio"},"author":{"name":"melindapham","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9"},"headline":"Build a Media Portfolio in 30 Minutes with Cloudinary Upload Widget, Next.js App Router, and Server Actions","datePublished":"2026-11-04T15:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio"},"wordCount":362,"publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.jpg?_i=AA","keywords":["Accessibility","Image","Image Transformation","Multi-CDN","Next.js","Upload"],"inLanguage":"en-US","copyrightYear":"2026","copyrightHolder":{"@id":"https:\/\/cloudinary.com\/#organization"}},{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio","url":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio","name":"Build a Cloudinary Media Portfolio With Next.js in 30 Minutes","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio#primaryimage"},"image":{"@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.jpg?_i=AA","datePublished":"2026-11-04T15:00:00+00:00","description":"Build a responsive Next.js portfolio with Cloudinary Upload Widget, automatic AI alt text and captions, Image Generation, transformations, and CDN delivery.","breadcrumb":{"@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio#primaryimage","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.jpg?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.jpg?_i=AA","width":4000,"height":2200},{"@type":"BreadcrumbList","@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/cloudinary.com\/blog"},{"@type":"ListItem","position":2,"name":"Build a Media Portfolio in 30 Minutes with Cloudinary Upload Widget, Next.js App Router, and Server Actions"}]},{"@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:\/\/secure.gravatar.com\/avatar\/e6f989fa97fe94be61596259d8629c3df65aec4c7da5c0000f90d810f313d4f4?s=96&d=mm&r=g","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\/cloudinary-nextjs-media-portfolio","meta":{"@context":"https:\/\/schema.org","@type":"NewsArticle","headline":"Build a Media Portfolio in 30 Minutes with Cloudinary Upload Widget, Next.js App Router, and Server Actions","url":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.jpg?_i=AA&w=150&h=150&crop=1","image":{"@type":"ImageObject","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.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":["accessibility","image","image transformation","multi-cdn","next.js","upload"],"dateCreated":"2026-11-04T15:00:00Z","datePublished":"2026-11-04T15:00:00Z","dateModified":"2026-11-04T15:00:00Z"},"rendered":"<meta name=\"parsely-title\" content=\"Build a Media Portfolio in 30 Minutes with Cloudinary Upload Widget, Next.js App Router, and Server Actions\" \/>\n<meta name=\"parsely-link\" content=\"https:\/\/cloudinary.com\/blog\/cloudinary-nextjs-media-portfolio\" \/>\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\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.jpg?_i=AA&w=150&amp;h=150&amp;crop=1\" \/>\n<meta name=\"parsely-pub-date\" content=\"2026-11-04T15:00:00Z\" \/>\n<meta name=\"parsely-section\" content=\"Uncategorized\" \/>\n<meta name=\"parsely-tags\" content=\"accessibility,image,image transformation,multi-cdn,next.js,upload\" \/>\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\/v1793316738\/Web_Assets\/blog\/Blog_Build_a_Media_Portfolio\/Blog_Build_a_Media_Portfolio.jpg?_i=AA","_links":{"self":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40641","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=40641"}],"version-history":[{"count":1,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40641\/revisions"}],"predecessor-version":[{"id":40642,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40641\/revisions\/40642"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media\/40643"}],"wp:attachment":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media?parent=40641"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/categories?post=40641"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/tags?post=40641"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}