{"id":40209,"date":"2026-07-17T07:00:00","date_gmt":"2026-07-17T14:00:00","guid":{"rendered":"https:\/\/cloudinary.com\/blog\/?p=40209"},"modified":"2026-07-17T12:49:08","modified_gmt":"2026-07-17T19:49:08","slug":"world-cup-card-game-nextjs-cloudinary-supabase-vercel","status":"publish","type":"post","link":"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel","title":{"rendered":"World Cup Card Battler: Turn Your Selfie Into a Football Card With Next.js, Cloudinary, Supabase, and Vercel"},"content":{"rendered":"\n<p>The World Cup is on, half my feed is football, and I wanted an excuse to build something. So I built&nbsp;<strong>Kickoff Cards<\/strong>: Upload a selfie, pick your nation, and get a playable football trading card. With it, you can battle other people&#8217;s cards, earn XP, and climb a live leaderboard.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><a href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#the-game\"><\/a>The Game<\/h2>\n\n\n\n<p>The idea is simple: Turn yourself into a football trading card. Think of a Panini sticker or a FIFA Ultimate Team card, except the player on it is <em>you<\/em>.<\/p>\n\n\n\n<p>All you have to do is upload a selfie and pick a nation. After a few seconds, you get a card back: your face cut out and dropped into the frame in your nation&#8217;s colors, with six performance stats down the side (pace, shooting, passing, and so on). There&#8217;s no signup to get this far; you get an anonymous session on first load and the card belongs to you. Anything you upload is run through automated moderation before it appears in the public gallery.<\/p>\n\n\n\n<p>Then you play it. Send your card into the battle arena, get matched against another player&#8217;s cards, and go head to head across your performance stats. Winning earns XP, enough XP levels the card up, and a level-up nudges its stats and regenerates the card image to match. A leaderboard tracks who&#8217;s ahead and updates in real time as battles resolve.<\/p>\n\n\n\n<p>That&#8217;s the whole loop. Make a card, fight with it, level it up, and climb the board. It&#8217;s small, but it touches most of what a real user-media app has to deal with: uploads, AI image processing, moderation, live state, and public content.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img decoding=\"async\" src=\"https:\/\/cloudinary-marketing-res.cloudinary.com\/image\/upload\/v1784234855\/blog-World_Cup_Card_Battler-_Turn_Your_Selfie_Into_a_Football_Card_With_Next.js_Cloudinary_Supabase_and_Vercel-1.webp\" alt=\"Generated battle cards with head-to-head stats and the winner.\"\/><\/figure>\n\n\n\n<p>The card game is the demo. What this post really focuses on is how the four tools it&#8217;s built on fit together.<\/p>\n\n\n\n<p>I chose tooling that works well with agentic development environments \u2014 current, trustworthy docs and MCP servers that let the agent check its work against the real project rather than guess from training data. Next.js, Cloudinary, Supabase, and Vercel are leaders in this field, and that&#8217;s a fair part of why this came together quickly. (More on that later.)<\/p>\n\n\n\n<p>The split is clean: Next.js owns the app, Cloudinary owns media, Supabase owns data, and Vercel ships it, each handing off to the others through stable APIs. It&#8217;s open source and runs entirely on free tiers. You can play it at&nbsp;<strong><a href=\"https:\/\/kickoff-cards.vercel.app\/\" target=\"_blank\" rel=\"noreferrer noopener\">kickoff-cards.vercel.app<\/a><\/strong>, then fork the code and run yourself:&nbsp;<strong><a href=\"https:\/\/github.com\/cloudinary-devs\/kickoff-cards\" target=\"_blank\" rel=\"noreferrer noopener\">github.com\/cloudinary-devs\/kickoff-cards<\/a><\/strong>.<\/p>\n\n\n\n<p>Here&#8217;s the whole flow on one diagram:<\/p>\n\n\n<pre class=\"wp-block-code\"><span><code class=\"hljs shcb-wrap-lines\">Browser\n  \u2502\n  \u251c\u2500 GET page \u2500\u2500\u2192 Next.js Server Component \u2500\u2500\u2192 Supabase (RLS)\n  \u2502                                        \u2514\u2500\u2192 Cloudinary URL builder\n  \u2502\n  \u251c\u2500 POST \/api\/sign-upload \u2500\u2500\u2192 signed upload params\n  \u2502\n  \u2514\u2500 Direct upload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2192 Cloudinary\n                                  \u2502\n                          Async moderation\n                                  \u2502\n                              Webhook \u2500\u2500\u2192 Supabase update \u2500\u2500\u2192 Realtime \u2500\u2500\u2192 Browser\n<\/code><\/span><\/pre>\n\n\n<p>The rest of this post walks through the decisions behind that diagram.<a href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#why-not-just-store-the-files\"><\/a><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why Not Just Store the Files?<\/h2>\n\n\n\n<p>The first decision was what to do with the uploaded photos. The easy version: save the file, serve it back, done. That holds up until you actually look at what a card needs, e.g., background removal, a face-aware crop, compositing onto the template, text overlays, modern formats, and content moderation. Do it yourself and now you&#8217;re maintaining a transform pipeline, a CDN, and a moderation step.<\/p>\n\n\n\n<p>I didn&#8217;t want to build that, so the image work goes to Cloudinary. I describe the transformation I want, get a URL back, and it generates and caches the result on first request. The useful side effect is that a finished card ends up being a single URL with no server-side rendering, which I&#8217;ll come back to.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><a href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#uploads-that-never-touch-my-server\"><\/a>Uploads That Never Touch My Server<\/h2>\n\n\n\n<p>A lot of upload flows go browser &gt; your server &gt; storage, which means your server parses multipart bodies, buffers files, and streams them back out. Kickoff Cards skips that. The server signs a set of upload parameters, hands them to the browser, and the browser uploads straight to Cloudinary. The only thing my code produces is a signature.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" 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\">getSignedUploadParams<\/span>(<span class=\"hljs-params\">{ userId, kind }<\/span>) <\/span>{\n  <span class=\"hljs-keyword\">const<\/span> timestamp = <span class=\"hljs-built_in\">Math<\/span>.round(<span class=\"hljs-built_in\">Date<\/span>.now() \/ <span class=\"hljs-number\">1000<\/span>)\n\n  <span class=\"hljs-keyword\">const<\/span> publicId =\n    kind === <span class=\"hljs-string\">\"avatar\"<\/span>\n      ? <span class=\"hljs-string\">`kickoff\/users\/<span class=\"hljs-subst\">${userId}<\/span>\/avatar`<\/span>\n      : <span class=\"hljs-string\">`kickoff\/users\/<span class=\"hljs-subst\">${userId}<\/span>\/uploads\/<span class=\"hljs-subst\">${crypto.randomUUID()}<\/span>`<\/span>\n\n  <span class=\"hljs-keyword\">const<\/span> signature = cloudinary.utils.api_sign_request(\n    { timestamp, <span class=\"hljs-attr\">public_id<\/span>: publicId, overwrite, moderation, notification_url },\n    env.CLOUDINARY_API_SECRET,\n  )\n\n  <span class=\"hljs-keyword\">return<\/span> { signature, timestamp, apiKey, cloudName, publicId }\n}\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><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\n\n<p>Because the signature covers every parameter, the client can&#8217;t change the destination path, moderation settings, webhook URL, or overwrite behavior without invalidating it. You get direct uploads while the server keeps control of where files land.<\/p>\n\n\n\n<p>This isn&#8217;t an upload preset. Presets work for static config, but they aren&#8217;t user-aware. They can&#8217;t carry the authenticated user&#8217;s ID, a per-request destination, or a dynamic webhook. That gets generated server-side and signed; the browser submits it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><a href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#sessions-without-signup\"><\/a>Sessions Without Signup<\/h2>\n\n\n\n<p>That&nbsp;<code>userId<\/code>&nbsp;in the upload path comes from Supabase. There&#8217;s no login. Supabase Anonymous Auth gives each visitor a real authenticated session on first load, backed by a cookie and a stable user ID, and their cards, battles, and XP all hang off of that ID. Uploads are scoped to it (<code>kickoff\/users\/{userId}\/...<\/code>), so a card belongs to the right person without any extra plumbing.<\/p>\n\n\n\n<p>It&#8217;s a reasonable fit for a low-friction demo, and it isn&#8217;t a dead end. If I later add OAuth or magic links, anonymous is just another provider and the data model doesn&#8217;t change.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><a href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#a-whole-trading-card-is-one-url\"><\/a>A Whole Trading Card is One URL<\/h2>\n\n\n\n<p>The finished card (background removed, face cropped into the window, six stats printed at set coordinates, watermark on the bottom) isn&#8217;t rendered on a server. No canvas, no image endpoint, nothing written to disk. It&#8217;s a single Cloudinary URL.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" 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> transformation = &#91;\n  { <span class=\"hljs-attr\">overlay<\/span>: layerId },\n  { <span class=\"hljs-attr\">effect<\/span>: <span class=\"hljs-string\">\"background_removal\"<\/span> },\n  { <span class=\"hljs-attr\">crop<\/span>: <span class=\"hljs-string\">\"fill\"<\/span>, <span class=\"hljs-attr\">gravity<\/span>: <span class=\"hljs-string\">\"face\"<\/span>, <span class=\"hljs-attr\">width<\/span>: tmpl.photo.w, <span class=\"hljs-attr\">height<\/span>: tmpl.photo.h },\n  { <span class=\"hljs-attr\">flags<\/span>: <span class=\"hljs-string\">\"layer_apply\"<\/span>, <span class=\"hljs-attr\">gravity<\/span>: tmpl.photo.gravity, <span class=\"hljs-attr\">x<\/span>: tmpl.photo.x, <span class=\"hljs-attr\">y<\/span>: tmpl.photo.y },\n\n  <span class=\"hljs-comment\">\/\/ six stat overlays, one per attribute<\/span>\n  ...STAT_ATTRS.flatMap(<span class=\"hljs-function\">(<span class=\"hljs-params\">attr<\/span>) =&gt;<\/span> &#91;\n    { <span class=\"hljs-attr\">overlay<\/span>: { <span class=\"hljs-attr\">font_family<\/span>: <span class=\"hljs-string\">\"Arial\"<\/span>, <span class=\"hljs-attr\">font_size<\/span>: <span class=\"hljs-number\">44<\/span>, <span class=\"hljs-attr\">font_weight<\/span>: <span class=\"hljs-string\">\"bold\"<\/span>, <span class=\"hljs-attr\">text<\/span>: <span class=\"hljs-built_in\">String<\/span>(stats&#91;attr]) }, <span class=\"hljs-attr\">color<\/span>: <span class=\"hljs-string\">\"black\"<\/span> },\n    { <span class=\"hljs-attr\">flags<\/span>: <span class=\"hljs-string\">\"layer_apply\"<\/span>, <span class=\"hljs-attr\">gravity<\/span>: <span class=\"hljs-string\">\"north\"<\/span>, <span class=\"hljs-attr\">x<\/span>: slot.x, <span class=\"hljs-attr\">y<\/span>: slot.y },\n  ]),\n\n  { <span class=\"hljs-attr\">overlay<\/span>: <span class=\"hljs-string\">\"kickoff:cloudinary-logo\"<\/span>, <span class=\"hljs-attr\">width<\/span>: <span class=\"hljs-number\">220<\/span>, <span class=\"hljs-attr\">crop<\/span>: <span class=\"hljs-string\">\"scale\"<\/span>, <span class=\"hljs-attr\">opacity<\/span>: <span class=\"hljs-number\">55<\/span> },\n  { <span class=\"hljs-attr\">flags<\/span>: <span class=\"hljs-string\">\"layer_apply\"<\/span>, <span class=\"hljs-attr\">gravity<\/span>: <span class=\"hljs-string\">\"south\"<\/span>, <span class=\"hljs-attr\">y<\/span>: <span class=\"hljs-number\">28<\/span> },\n  { <span class=\"hljs-attr\">width<\/span>: <span class=\"hljs-number\">800<\/span>, <span class=\"hljs-attr\">crop<\/span>: <span class=\"hljs-string\">\"limit\"<\/span> },\n]\n\n<span class=\"hljs-keyword\">return<\/span> cloudinary.url(<span class=\"hljs-string\">`templates\/<span class=\"hljs-subst\">${nation}<\/span>`<\/span>, {\n  transformation,\n  <span class=\"hljs-attr\">fetch_format<\/span>: <span class=\"hljs-string\">\"auto\"<\/span>,\n  <span class=\"hljs-attr\">quality<\/span>: <span class=\"hljs-string\">\"auto\"<\/span>,\n  <span class=\"hljs-attr\">secure<\/span>: <span class=\"hljs-literal\">true<\/span>,\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\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Cloudinary runs that chain the first time the URL is requested, caches the result, and serves subsequent requests from the CDN. There&#8217;s no image-processing server to run.<\/p>\n\n\n\n<p>A few things to watch for:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Order.<\/strong>&nbsp;Background removal has to come before the crop. Flip them and Cloudinary crops the original first, then removes the background from that rectangle, which gives a worse result.<\/li>\n\n\n\n<li><strong><code>gravity: \"face\"<\/code>.<\/strong>&nbsp;Without it, a full-body selfie tends to crop to the chest rather than the face.<\/li>\n\n\n\n<li><strong>Layer IDs use colons, not slashes.<\/strong>&nbsp;<code>kickoff\/users\/abc\/uploads\/xyz<\/code>&nbsp;becomes&nbsp;<code>kickoff:users:abc:uploads:xyz<\/code>. Small detail, easy to miss when debugging transformation URLs.<\/li>\n\n\n\n<li><strong>Cache busting is automatic.<\/strong>&nbsp;When a player levels up, I rebuild the URL with the new stats. A different URL is a different cache key, so Cloudinary renders the updated card on the next request, with no purge calls or CDN management.<\/li>\n<\/ul>\n\n\n\n<p>Every browser-facing image ends with&nbsp;<code>fetch_format: \"auto\"<\/code>&nbsp;and&nbsp;<code>quality: \"auto\"<\/code>, so Cloudinary serves WebP\/AVIF where supported and picks a compression level, usually smaller files with no extra config.<\/p>\n\n\n\n<p>One deliberate non-choice: I&#8217;m not using&nbsp;<code>next-cloudinary<\/code>. The card URLs are already complete transformation URLs stored in the database. Plain&nbsp;<code>next\/image<\/code>&nbsp;with&nbsp;<code>unoptimized<\/code>&nbsp;handles layout, sizing, and lazy-loading, while Cloudinary stays responsible for the media. That&#8217;s one fewer abstraction between the app and the transformation pipeline.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Moderation on Upload<\/h2>\n\n\n\n<p>If you accept user images, you need moderation, because a fraction of &#8220;selfies&#8221; won&#8217;t be selfies. The DIY version is a vision API plus a queue, retries, and another set of credentials. Cloudinary handles it as part of the upload instead.<\/p>\n\n\n\n<p>Every signed upload carries an AWS Rekognition moderation string:<\/p>\n\n\n<pre class=\"wp-block-code\" 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\">aws_rek<\/span><span class=\"hljs-selector-pseudo\">:explicit_nudity<\/span><span class=\"hljs-selector-pseudo\">:0.4<\/span><span class=\"hljs-selector-pseudo\">:suggestive<\/span><span class=\"hljs-selector-pseudo\">:0.4<\/span><span class=\"hljs-selector-pseudo\">:violence<\/span><span class=\"hljs-selector-pseudo\">:0.4<\/span><\/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\n\n<p>After the upload, Cloudinary runs moderation asynchronously and POSTs the verdict to my webhook, which verifies the signature, reads the status, updates the row in Supabase, and deletes anything rejected. The user watches their card flip from&nbsp;<strong>Pending<\/strong>&nbsp;to&nbsp;<strong>Approved<\/strong>&nbsp;(or get bounced) without refreshing, because that database write feeds a Realtime subscription. That&#8217;s where Supabase comes in.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><a href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#supabase-the-right-key-for-the-right-job\"><\/a>Supabase: The Right Key for the Right Job<\/h2>\n\n\n\n<p>I use three Supabase clients on purpose, and keeping them separate avoids a common class of security bug.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Client<\/th><th>Key<\/th><th>Job<\/th><\/tr><\/thead><tbody><tr><td><code>lib\/supabase\/server.ts<\/code><\/td><td>Anon<\/td><td>Server Components, Route Handlers, Server Actions (RLS enforced)<\/td><\/tr><tr><td><code>lib\/supabase\/client.ts<\/code><\/td><td>Anon<\/td><td>Browser Realtime subscriptions only<\/td><\/tr><tr><td><code>lib\/supabase\/admin.ts<\/code><\/td><td>Service role<\/td><td>Webhooks and privileged server work (bypasses RLS)<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>The service-role key bypasses Row Level Security, so it lives in one&nbsp;<code>server-only<\/code>&nbsp;module. If it&#8217;s accidentally imported into client code, the build fails rather than leaking the key.<\/p>\n\n\n\n<p>Two places use it intentionally:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>The <strong>moderation webhook<\/strong> you just saw (which has to delete any card).<\/li>\n\n\n\n<li><strong>Battle resolution<\/strong> (the&nbsp;<code>battles<\/code>&nbsp;table has&nbsp;<code>WITH CHECK (false)<\/code>&nbsp;on insert, so results can&#8217;t be written from the client).<\/li>\n<\/ol>\n\n\n\n<p>Everything else runs under normal RLS, which is easier to reason about than one client that&#8217;s sometimes an admin and sometimes not.<a href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#live-updates-without-polling\"><\/a><\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Live Updates Without Polling<\/h2>\n\n\n\n<p>Two bits of UI update live: the leaderboard and moderation status. Both use the same pattern: Subscribe to a table and let Postgres changes push to the client.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\"><span class=\"hljs-keyword\">const<\/span> channel = supabase\n  .channel(<span class=\"hljs-string\">\"leaderboard\"<\/span>)\n  .on(<span class=\"hljs-string\">\"postgres_changes\"<\/span>, { event: <span class=\"hljs-string\">\"*\"<\/span>, schema: <span class=\"hljs-string\">\"public\"<\/span>, table: <span class=\"hljs-string\">\"profiles\"<\/span> }, () =&gt; {\n    router.refresh()\n  })\n  .subscribe()<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><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\n\n<p>When a battle changes someone&#8217;s XP, the leaderboard re-renders. When Cloudinary&#8217;s webhook updates a card, the card page reacts. No polling and no WebSocket server to run yourself:<\/p>\n\n\n<pre class=\"wp-block-code\"><span><code class=\"hljs shcb-wrap-lines\">External service \u2192 Webhook \u2192 Postgres \u2192 Supabase Realtime \u2192 Browser<\/code><\/span><\/pre>\n\n\n<p>The same shape works for other async work: payments, AI jobs, background processing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><a href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#building-this-with-an-agent\"><\/a>Building This With an Agent<\/h2>\n\n\n\n<p>Here&#8217;s the &#8220;more on that&#8221; from the intro. The reason the stack choice mattered is that all three of Cloudinary, Supabase and Vercel expose MCP servers, so the agent can query the actual project rather than rely on training data. It can browse uploaded assets and test a transformation in Cloudinary, check the live schema and RLS policies in Supabase, and read deployment logs in Vercel. For a media-heavy app that&#8217;s the difference between the agent guessing and the agent checking: instead of hardcoding a URL and refreshing a tab to see if a transformation worked, it verifies the result directly.<\/p>\n\n\n\n<p>The repo supports this with an&nbsp;<code>AGENTS.md<\/code>&nbsp;(architecture rules, conventions, and which dependencies move fast enough that it should fetch current docs) and vendored skills under&nbsp;<code>.agents\/skills\/<\/code>&nbsp;for Supabase and Postgres, so it works from current guidance when writing migrations.<\/p>\n\n\n\n<p>Nothing here is agent-specific, really. The same things that make the codebase easy for an agent to work in (clear boundaries, one place per concern, services it can query directly) are what make it easy for a person. The agent just makes it obvious when you&#8217;ve got those wrong.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Run It<\/h2>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\">git <span class=\"hljs-keyword\">clone<\/span> https:<span class=\"hljs-comment\">\/\/github.com\/cloudinary-devs\/kickoff-cards<\/span>\ncd kickoff-cards\npnpm install\ncp .env.example .env.local\npnpm dev\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><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\n\n<p>You&#8217;ll need a free Cloudinary account with the AWS Rekognition add-on enabled (the free quota is plenty), a Supabase project with anonymous auth on, and the schema from&nbsp;<code>supabase\/schema.sql<\/code>. For local webhook testing, a Cloudflare tunnel does the job without an account:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"PHP\" data-shcb-language-slug=\"php\"><span><code class=\"hljs language-php shcb-wrap-lines\">cloudflared tunnel --url http:<span class=\"hljs-comment\">\/\/localhost:3000<\/span>\n<span class=\"hljs-comment\"># paste the HTTPS URL into CLOUDINARY_WEBHOOK_URL<\/span>\n<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><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\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>Full walkthrough is in&nbsp;<code>docs\/getting-started.md<\/code>.<\/p>\n<\/blockquote>\n\n\n\n<h2 class=\"wp-block-heading\"><a href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#what-carries-over\"><\/a>What Carries Over<\/h2>\n\n\n\n<p>Aside from the football, these are the patterns that apply to most apps handling user media:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Signed direct uploads.<\/strong>&nbsp;The browser uploads to Cloudinary, the server only signs. The file never passes through your app.<\/li>\n\n\n\n<li><strong>One transformation builder.<\/strong>&nbsp;Keep the transform logic in a single function rather than scattered across components.<\/li>\n\n\n\n<li><strong>Separate Supabase clients.<\/strong>&nbsp;Anon, browser, and service-role as distinct responsibilities, so RLS isn&#8217;t bypassed by accident.<\/li>\n\n\n\n<li><strong>Webhook + Realtime.<\/strong>&nbsp;External service &gt; webhook &gt; database &gt; Realtime &gt; browser. No polling, and it generalizes to moderation, payments, and background jobs.<\/li>\n<\/ul>\n\n\n\n<p>None of these are specific to a card game, or to this stack. They&#8217;re just what fell out of giving each service one clear job and letting it do it.<\/p>\n\n\n\n<p>If you want to poke at the code, most of it lives in&nbsp;<code>lib\/cloudinary\/<\/code>&nbsp;and&nbsp;<code>lib\/supabase\/<\/code>. It&#8217;s MIT-licensed, so take what&#8217;s useful.<\/p>\n\n\n\n<p>Ready to level up your media workflow? <a href=\"https:\/\/cloudinary.com\/users\/register_free\">Start using Cloudinary for free<\/a> and build better visual experiences today.<\/p>\n\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#frequently_asked_questions {\n    margin-bottom: 20px;\n}\n.question {\n    margin-bottom: 20px;\n}\n<\/style>\n\n<div class=\"faqs\">\n\n<h2>Frequently Asked Questions<\/h2>\n\n<div class=\"question\">\n<p><b>How do you build a secure image upload workflow in Next.js?<\/b><\/p>\nA secure upload workflow avoids routing image files through your application server. Instead, your Next.js backend generates a <a href=\"https:\/\/cloudinary.com\/documentation\/upload_images#generating_authentication_signatures\">signed upload request<\/a>, and the browser uploads directly to Cloudinary. This approach reduces server load, prevents users from modifying upload parameters, and allows you to enforce destination folders, moderation settings, and webhook URLs through signed requests.\n<\/div>\n\n<div class=\"question\">\n<p><b>Why use Cloudinary instead of storing uploaded images yourself?<\/b><\/p>\nWhile storing files yourself is straightforward, production applications often require background removal, face-aware cropping, image optimization, CDN delivery, content moderation, responsive formats, and dynamic transformations. Cloudinary handles these capabilities automatically through <a href=\"https:\/\/cloudinary.com\/documentation\/image_transformations\">URL-based transformations<\/a>, allowing developers to focus on application logic instead of maintaining an image processing pipeline.\n<\/div>\n\n<div class=\"question\">\n<p><b>Can Cloudinary and Supabase be used together for production applications?<\/b><\/p>\nYes. Cloudinary and Supabase complement each other well in modern web applications. Cloudinary manages uploads, image transformations, optimization, moderation, and CDN delivery, while Supabase provides authentication, PostgreSQL, Row Level Security, real-time updates, and application data. Together they create a scalable architecture.\n<\/div>\n\n<div class=\"question\">\n<p><b>Is this architecture suitable for applications beyond a football card game?<\/b><\/p>\nAbsolutely. The same architecture works for social networks, community photo galleries, e-commerce platforms, AI-powered creative tools, event apps, marketplaces, and any application that accepts user-generated media. Patterns like signed direct uploads, URL-based image transformations, webhook-driven workflows, and real-time UI updates are widely used in production systems because they&#8217;re scalable, secure, and easy to extend.\n<\/div>\n\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>The World Cup is on, half my feed is football, and I wanted an excuse to build something. So I built&nbsp;Kickoff Cards: Upload a selfie, pick your nation, and get a playable football trading card. With it, you can battle other people&#8217;s cards, earn XP, and climb a live leaderboard. The Game The idea is [&hellip;]<\/p>\n","protected":false},"author":87,"featured_media":40227,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"_cloudinary_featured_overwrite":false,"footnotes":""},"categories":[1],"tags":[409,203,212,396],"class_list":["post-40209","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-generative-ai","tag-moderation","tag-next-js","tag-vercel"],"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 World Cup Card Game With Cloudinary, Next.js, Supabase &amp; Vercel<\/title>\n<meta name=\"description\" content=\"Build a World Cup football card game that turns selfies into AI-generated player cards using Next.js, Cloudinary, Supabase, and Vercel, with image moderation, real-time leaderboards, and direct uploads.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"World Cup Card Battler: Turn Your Selfie Into a Football Card With Next.js, Cloudinary, Supabase, and Vercel\" \/>\n<meta property=\"og:description\" content=\"Build a World Cup football card game that turns selfies into AI-generated player cards using Next.js, Cloudinary, Supabase, and Vercel, with image moderation, real-time leaderboards, and direct uploads.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88\" \/>\n<meta property=\"og:site_name\" content=\"Cloudinary Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-07-17T14:00:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-07-17T19:49:08+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA\" \/>\n\t<meta property=\"og:image:width\" content=\"4000\" \/>\n\t<meta property=\"og:image:height\" content=\"2200\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/jpeg\" \/>\n<meta name=\"author\" content=\"melindapham\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"NewsArticle\",\"@id\":\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#article\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel\"},\"author\":{\"name\":\"melindapham\",\"@id\":\"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9\"},\"headline\":\"World Cup Card Battler: Turn Your Selfie Into a Football Card With Next.js, Cloudinary, Supabase, and Vercel\",\"datePublished\":\"2026-07-17T14:00:00+00:00\",\"dateModified\":\"2026-07-17T19:49:08+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel\"},\"wordCount\":2107,\"publisher\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA\",\"keywords\":[\"Generative AI\",\"Moderation\",\"Next.js\",\"Vercel\"],\"inLanguage\":\"en-US\",\"copyrightYear\":\"2026\",\"copyrightHolder\":{\"@id\":\"https:\/\/cloudinary.com\/#organization\"}},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel\",\"url\":\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88\",\"name\":\"Build a World Cup Card Game With Cloudinary, Next.js, Supabase & Vercel\",\"isPartOf\":{\"@id\":\"https:\/\/cloudinary.com\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#primaryimage\"},\"image\":{\"@id\":\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#primaryimage\"},\"thumbnailUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA\",\"datePublished\":\"2026-07-17T14:00:00+00:00\",\"dateModified\":\"2026-07-17T19:49:08+00:00\",\"description\":\"Build a World Cup football card game that turns selfies into AI-generated player cards using Next.js, Cloudinary, Supabase, and Vercel, with image moderation, real-time leaderboards, and direct uploads.\",\"breadcrumb\":{\"@id\":\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#primaryimage\",\"url\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA\",\"contentUrl\":\"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA\",\"width\":4000,\"height\":2200},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/cloudinary.com\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"World Cup Card Battler: Turn Your Selfie Into a Football Card With Next.js, Cloudinary, Supabase, and Vercel\"}]},{\"@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 World Cup Card Game With Cloudinary, Next.js, Supabase & Vercel","description":"Build a World Cup football card game that turns selfies into AI-generated player cards using Next.js, Cloudinary, Supabase, and Vercel, with image moderation, real-time leaderboards, and direct uploads.","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:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88","og_locale":"en_US","og_type":"article","og_title":"World Cup Card Battler: Turn Your Selfie Into a Football Card With Next.js, Cloudinary, Supabase, and Vercel","og_description":"Build a World Cup football card game that turns selfies into AI-generated player cards using Next.js, Cloudinary, Supabase, and Vercel, with image moderation, real-time leaderboards, and direct uploads.","og_url":"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88","og_site_name":"Cloudinary Blog","article_published_time":"2026-07-17T14:00:00+00:00","article_modified_time":"2026-07-17T19:49:08+00:00","og_image":[{"width":4000,"height":2200,"url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA","type":"image\/jpeg"}],"author":"melindapham","twitter_card":"summary_large_image","schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"NewsArticle","@id":"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#article","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel"},"author":{"name":"melindapham","@id":"https:\/\/cloudinary.com\/blog\/#\/schema\/person\/0d5ad601e4c3b5be89245dfb14be42d9"},"headline":"World Cup Card Battler: Turn Your Selfie Into a Football Card With Next.js, Cloudinary, Supabase, and Vercel","datePublished":"2026-07-17T14:00:00+00:00","dateModified":"2026-07-17T19:49:08+00:00","mainEntityOfPage":{"@id":"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel"},"wordCount":2107,"publisher":{"@id":"https:\/\/cloudinary.com\/blog\/#organization"},"image":{"@id":"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA","keywords":["Generative AI","Moderation","Next.js","Vercel"],"inLanguage":"en-US","copyrightYear":"2026","copyrightHolder":{"@id":"https:\/\/cloudinary.com\/#organization"}},{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel","url":"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88","name":"Build a World Cup Card Game With Cloudinary, Next.js, Supabase & Vercel","isPartOf":{"@id":"https:\/\/cloudinary.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#primaryimage"},"image":{"@id":"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#primaryimage"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA","datePublished":"2026-07-17T14:00:00+00:00","dateModified":"2026-07-17T19:49:08+00:00","description":"Build a World Cup football card game that turns selfies into AI-generated player cards using Next.js, Cloudinary, Supabase, and Vercel, with image moderation, real-time leaderboards, and direct uploads.","breadcrumb":{"@id":"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#primaryimage","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA","contentUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA","width":4000,"height":2200},{"@type":"BreadcrumbList","@id":"https:\/\/dev.to\/cloudinary\/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/cloudinary.com\/blog\/"},{"@type":"ListItem","position":2,"name":"World Cup Card Battler: Turn Your Selfie Into a Football Card With Next.js, Cloudinary, Supabase, and Vercel"}]},{"@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\/world-cup-card-game-nextjs-cloudinary-supabase-vercel","smart_links":{"inbound":0,"outbound":0},"traffic_boost_suggestions_count":0,"meta":{"@context":"https:\/\/schema.org","@type":"NewsArticle","headline":"World Cup Card Battler: Turn Your Selfie Into a Football Card With Next.js, Cloudinary, Supabase, and Vercel","url":"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel","mainEntityOfPage":{"@type":"WebPage","@id":"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel"},"thumbnailUrl":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA&w=150&h=150&crop=1","image":{"@type":"ImageObject","url":"https:\/\/res.cloudinary.com\/cloudinary-marketing\/images\/f_auto,q_auto\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.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":["generative ai","moderation","next.js","vercel"],"dateCreated":"2026-07-17T14:00:00Z","datePublished":"2026-07-17T14:00:00Z","dateModified":"2026-07-17T19:49:08Z"},"rendered":"<meta name=\"parsely-title\" content=\"World Cup Card Battler: Turn Your Selfie Into a Football Card With Next.js, Cloudinary, Supabase, and Vercel\" \/>\n<meta name=\"parsely-link\" content=\"https:\/\/cloudinary.com\/blog\/world-cup-card-game-nextjs-cloudinary-supabase-vercel\" \/>\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\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA&w=150&amp;h=150&amp;crop=1\" \/>\n<meta name=\"parsely-pub-date\" content=\"2026-07-17T14:00:00Z\" \/>\n<meta name=\"parsely-section\" content=\"Uncategorized\" \/>\n<meta name=\"parsely-tags\" content=\"generative ai,moderation,next.js,vercel\" \/>\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\/v1784317714\/Web_Assets\/blog\/Blog_World_Cup_Cards_Blog\/Blog_World_Cup_Cards_Blog.jpg?_i=AA","_links":{"self":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40209","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=40209"}],"version-history":[{"count":6,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40209\/revisions"}],"predecessor-version":[{"id":40226,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/posts\/40209\/revisions\/40226"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media\/40227"}],"wp:attachment":[{"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/media?parent=40209"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/categories?post=40209"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cloudinary.com\/blog\/wp-json\/wp\/v2\/tags?post=40209"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}