<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Massvai Notes]]></title><description><![CDATA[Massvai Notes]]></description><link>https://massvainotes.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 20 Oct 2026 15:17:43 GMT</lastBuildDate><atom:link href="https://massvainotes.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Scheduled Jobs for a Next.js + Supabase App: Vercel Cron vs pg_cron vs Edge Functions]]></title><description><![CDATA[Almost every real app needs something to run on a timer: delete expired sessions, send a daily digest, retry failed webhooks, recompute a leaderboard. If…]]></description><link>https://massvainotes.hashnode.dev/scheduled-jobs-for-a-next-js-supabase-app-vercel-cron-vs-pg-cron-vs-edge-functions</link><guid isPermaLink="true">https://massvainotes.hashnode.dev/scheduled-jobs-for-a-next-js-supabase-app-vercel-cron-vs-pg-cron-vs-edge-functions</guid><category><![CDATA[Next.js]]></category><category><![CDATA[supabase]]></category><category><![CDATA[Vercel]]></category><category><![CDATA[cron]]></category><dc:creator><![CDATA[Massvai Team]]></dc:creator><pubDate>Sat, 17 Oct 2026 16:22:30 GMT</pubDate><content:encoded><![CDATA[<p>Almost every real app needs something to run on a timer: delete expired sessions, send a daily digest, retry failed webhooks, recompute a leaderboard. If your stack is Next.js on Vercel with Supabase as the backend, you have three common places to put that job. They overlap, but each one fails in its own way. Here is how I decide.</p>
<h2>Option 1: Vercel Cron calling a Route Handler</h2>
<p>You add a <code>crons</code> entry to <code>vercel.json</code>, and Vercel sends a GET request to a path in your app on that schedule:</p>
<pre><code class="language-json">{
  "crons": [
    { "path": "/api/cron/cleanup", "schedule": "0 3 * * *" }
  ]
}
</code></pre>
<pre><code class="language-ts">// app/api/cron/cleanup/route.ts
import { createClient } from "@supabase/supabase-js";

export async function GET(request: Request) {
  if (request.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
  const { error } = await supabase
    .from("sessions")
    .delete()
    .lt("expires_at", new Date().toISOString());
  if (error) return new Response(error.message, { status: 500 });
  return Response.json({ ok: true });
}
</code></pre>
<p><strong>Good for:</strong> jobs that reuse your app code, call third-party APIs with secrets you already keep in Vercel, or render emails with the same templates as your UI.</p>
<p><strong>Watch out for:</strong></p>
<ul>
<li>Schedules are in UTC, and cron jobs only run on production deployments, not previews.</li>
<li>How often a job can run, and how precisely it is timed, depends on your Vercel plan. Check the current limits before you design around "every 5 minutes".</li>
<li>The route is a public URL. Set <code>CRON_SECRET</code> in your project's environment variables and check the <code>Authorization</code> header as above, or anyone can trigger the job.</li>
<li>The job is a normal serverless function, so it has the same maximum duration. Batch the work instead of looping over every row in one request.</li>
</ul>
<h2>Option 2: pg_cron inside Supabase</h2>
<p>If the job is pure SQL, it doesn't need to leave the database at all. Enable the <code>pg_cron</code> extension (Supabase also exposes it as the Cron integration in the dashboard) and schedule a statement:</p>
<pre><code class="language-sql">select cron.schedule(
  'cleanup-expired-sessions',
  '0 3 * * *',
  $$ delete from public.sessions where expires_at &lt; now() $$
);
</code></pre>
<p>Run history lands in <code>cron.job_run_details</code>, so you can see failures with a query:</p>
<pre><code class="language-sql">select jobid, status, return_message, start_time
from cron.job_run_details
order by start_time desc
limit 20;
</code></pre>
<p><strong>Good for:</strong> cleanup, rollups, refreshing materialized views, anything that is a few SQL statements.</p>
<p><strong>Watch out for:</strong></p>
<ul>
<li>pg_cron jobs run as a database role and are not subject to the Row Level Security you designed for users. Treat every scheduled statement like admin code and review it as such.</li>
<li>Long statements hold locks. Delete in batches (<code>where id in (select id ... limit 1000)</code>) instead of one giant <code>delete</code>.</li>
<li><code>cron.job_run_details</code> grows forever unless you prune it, so schedule a cleanup for the cleanup table too.</li>
</ul>
<h2>Option 3: pg_cron triggering a Supabase Edge Function</h2>
<p>When the job needs HTTP calls or code that isn't SQL, but you want it to live next to the database instead of in your web app, let pg_cron call an Edge Function through the <code>pg_net</code> extension:</p>
<pre><code class="language-sql">select cron.schedule(
  'send-daily-digest',
  '0 7 * * *',
  $$
  select net.http_post(
    url := 'https://YOUR_PROJECT.supabase.co/functions/v1/daily-digest',
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'digest_key')
    ),
    body := '{}'::jsonb
  );
  $$
);
</code></pre>
<p>Keep the key in Supabase Vault rather than pasting it into the job text, because job definitions are readable by anyone with database access.</p>
<p><strong>Good for:</strong> jobs that other clients don't need to know about, jobs that should keep running even if you redeploy or move the frontend, and webhook retries.</p>
<p><strong>Watch out for:</strong> <code>pg_net</code> is asynchronous. The cron job "succeeds" as soon as the request is queued, so a failing function won't show up in <code>cron.job_run_details</code>. Log results from inside the function (to a <code>job_runs</code> table, for example) so you can actually see failures.</p>
<h2>Six rules that apply to all three</h2>
<ol>
<li><strong>Make every job idempotent.</strong> Schedulers can retry or overlap. Running the job twice should do no extra harm; use <code>on conflict do nothing</code>, status columns, or a "last processed id" cursor.</li>
<li><strong>Prevent overlapping runs.</strong> If a run can take longer than the interval, take an advisory lock (<code>pg_try_advisory_lock</code>) or a row in a <code>job_locks</code> table, and exit if it's held.</li>
<li><strong>Process in batches.</strong> Small batches finish inside any timeout and are easy to resume.</li>
<li><strong>Record every run.</strong> A tiny table with <code>job_name</code>, <code>started_at</code>, <code>finished_at</code>, <code>rows_affected</code> and <code>error</code> turns "did the digest go out?" into a query.</li>
<li><strong>Keep secrets out of the schedule.</strong> Use <code>CRON_SECRET</code> on Vercel and Vault on Supabase.</li>
<li><strong>Write the schedule in UTC and say so.</strong> Put a comment next to each cron expression with the local time you meant.</li>
</ol>
<h2>Quick decision table</h2>
<table>
<thead>
<tr>
<th>Job</th>
<th>Where I'd put it</th>
</tr>
</thead>
<tbody><tr>
<td>Pure SQL cleanup or rollup</td>
<td>pg_cron</td>
</tr>
<tr>
<td>Needs your app's code, email templates or Vercel env vars</td>
<td>Vercel Cron + Route Handler</td>
</tr>
<tr>
<td>Needs HTTP or npm/Deno code, should outlive the frontend</td>
<td>pg_cron + Edge Function</td>
</tr>
</tbody></table>
<hr />
<p><em>Disclosure: I work on <a href="https://massvai.com">Massvai</a>, an AI app builder that generates full-stack Next.js apps with Supabase and Vercel. Scheduled jobs are one of the first things we wire up in a generated project, which is why this checklist exists.</em></p>
]]></content:encoded></item><item><title><![CDATA[Next.js App Router Caching with Supabase: 6 Rules to Avoid Stale (or Someone Else's) Data]]></title><description><![CDATA[The App Router caches aggressively, and Supabase queries are just fetch calls underneath. Put the two together without a plan and you get the classic bugs…]]></description><link>https://massvainotes.hashnode.dev/next-js-app-router-caching-with-supabase-6-rules-to-avoid-stale-or-someone-else-s-data</link><guid isPermaLink="true">https://massvainotes.hashnode.dev/next-js-app-router-caching-with-supabase-6-rules-to-avoid-stale-or-someone-else-s-data</guid><category><![CDATA[Next.js]]></category><category><![CDATA[supabase]]></category><category><![CDATA[webdev]]></category><category><![CDATA[caching]]></category><dc:creator><![CDATA[Massvai Team]]></dc:creator><pubDate>Sat, 17 Oct 2026 14:41:40 GMT</pubDate><content:encoded><![CDATA[<p>The App Router caches aggressively, and Supabase queries are just <code>fetch</code> calls underneath. Put the two together without a plan and you get the classic bugs: a dashboard that shows yesterday's numbers, a profile page that doesn't update after a save, or, worst case, one user's data rendered into a page that gets served to another. These six rules keep caching useful without those surprises.</p>
<h2>1. Anything that depends on the signed-in user is dynamic</h2>
<p>If a page or Server Component reads cookies to create a Supabase client with <code>@supabase/ssr</code>, it depends on the request. Reading <code>cookies()</code> already opts the route into dynamic rendering, but be explicit for user-specific routes:</p>
<pre><code class="language-ts">export const dynamic = 'force-dynamic'
</code></pre>
<p>Never wrap a user-scoped query in <code>unstable_cache</code> or a shared cache key. Row Level Security protects the database, not your cache: if the first result is cached, the second user gets it without RLS ever running.</p>
<h2>2. Cache public data on purpose, with a tag</h2>
<p>Public, shared data (a pricing table, a blog list, a public gallery) is where caching pays off. Use a Supabase client that does <strong>not</strong> read cookies (anon key only) and give the cache a tag and a lifetime:</p>
<pre><code class="language-ts">import { unstable_cache } from 'next/cache'

export const getPublicPosts = unstable_cache(
  async () =&gt; {
    const { data } = await supabasePublic.from('posts').select('id,title,slug').eq('published', true)
    return data ?? []
  },
  ['public-posts'],
  { tags: ['posts'], revalidate: 300 }
)
</code></pre>
<h2>3. Revalidate right after the write, in the same Server Action</h2>
<p>The "I saved it but the page didn't change" bug almost always means nobody told Next.js the data changed. Call <code>revalidateTag</code> or <code>revalidatePath</code> at the end of the mutation:</p>
<pre><code class="language-ts">'use server'
import { revalidateTag } from 'next/cache'

export async function publishPost(id: string) {
  const supabase = await createServerClient()
  const { error } = await supabase.from('posts').update({ published: true }).eq('id', id)
  if (error) throw error
  revalidateTag('posts')
}
</code></pre>
<p>Revalidate only after a successful write, otherwise you throw away a good cache for nothing.</p>
<h2>4. Writes from outside Next.js need a webhook into Next.js</h2>
<p>If data also changes from the Supabase dashboard, an Edge Function, or a cron job, your Server Actions never run, so nothing revalidates. Add a small Route Handler that calls <code>revalidateTag</code>, protect it with a shared secret, and call it from a Supabase Database Webhook on the relevant table. Without this, the time-based <code>revalidate</code> value is your only safety net.</p>
<h2>5. Don't let the router cache fool your testing</h2>
<p>On the client, the router keeps visited pages for a short time, so navigating back can show an older render even when the server is fresh. After a mutation from a Client Component, call <code>router.refresh()</code>, or rely on the Server Action's revalidation. When debugging, test with a hard reload before assuming the server cache is wrong.</p>
<h2>6. Realtime is for live views, not a replacement for revalidation</h2>
<p>Supabase Realtime is great for a feed, a chat, or a job-progress bar that must update without a reload. It doesn't update the server-rendered HTML that the next visitor receives. Use Realtime for the open tab and revalidation for everything else, and subscribe only to rows the user may see, since Realtime respects RLS only when it's enabled on the table.</p>
<h2>Quick checklist</h2>
<ul>
<li>User-specific route: dynamic, no shared cache.</li>
<li>Public data: cookie-free client, <code>unstable_cache</code> with tags and a lifetime.</li>
<li>Every write: <code>revalidateTag</code> after success.</li>
<li>Outside writes: database webhook to a secret-protected revalidate route.</li>
<li>Client mutations: <code>router.refresh()</code> when needed.</li>
<li>Live UI: Realtime on top, not instead.</li>
</ul>
<p><em>Disclosure: I work on <a href="https://massvai.com">Massvai</a>, an AI app builder that generates full-stack Next.js apps with Supabase and deploys them to Vercel. These rules apply to any Next.js and Supabase project, generated or hand-written.</em></p>
]]></content:encoded></item><item><title><![CDATA[Next.js + Supabase on Vercel: Env Vars, Redirect URLs and Preview Deployments That Don't Break Login]]></title><description><![CDATA[The app works on localhost:3000. You push to Vercel, and now one of three things happens: All three come from the same two places: how Next.js handles…]]></description><link>https://massvainotes.hashnode.dev/next-js-supabase-on-vercel-env-vars-redirect-urls-and-preview-deployments-that-don-t-break-login</link><guid isPermaLink="true">https://massvainotes.hashnode.dev/next-js-supabase-on-vercel-env-vars-redirect-urls-and-preview-deployments-that-don-t-break-login</guid><category><![CDATA[authentication]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[supabase]]></category><category><![CDATA[Vercel]]></category><dc:creator><![CDATA[Massvai Team]]></dc:creator><pubDate>Sun, 11 Oct 2026 21:23:24 GMT</pubDate><content:encoded><![CDATA[<p>The app works on <code>localhost:3000</code>. You push to Vercel, and now one of three things happens:</p>
<ol>
<li>The build fails with <code>supabaseUrl is required</code>.</li>
<li>Login works but sends you back to <code>http://localhost:3000/?code=...</code>.</li>
<li>Production is fine, but every preview deployment fails at the OAuth callback.</li>
</ol>
<p>All three come from the same two places: how Next.js handles environment variables, and how Supabase decides where to redirect. Here's a setup that handles production, previews and local dev.</p>
<h2>1. Environment variables: build time, not runtime</h2>
<p>Anything prefixed <code>NEXT_PUBLIC_</code> is inlined into the client bundle <strong>at build time</strong>. If the variable isn't set for the environment that runs the build, the bundle contains <code>undefined</code>, and setting it later changes nothing until you redeploy.</p>
<p>In Vercel → Project → Settings → Environment Variables, check that these are enabled for <strong>Production, Preview and Development</strong>:</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Exposed to browser</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td><code>NEXT_PUBLIC_SUPABASE_URL</code></td>
<td>yes</td>
<td>Project URL</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_SUPABASE_ANON_KEY</code> (or publishable key)</td>
<td>yes</td>
<td>Safe with RLS on</td>
</tr>
<tr>
<td><code>SUPABASE_SERVICE_ROLE_KEY</code> (or secret key)</td>
<td><strong>no</strong></td>
<td>Server only. Never prefix with <code>NEXT_PUBLIC_</code></td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_SITE_URL</code></td>
<td>yes</td>
<td><strong>Production only</strong> (see below)</td>
</tr>
</tbody></table>
<p>The common trap is variables created only for Production. Preview builds then fail with <code>supabaseUrl is required</code>. Open the "..." menu on each variable and tick all environments, then redeploy.</p>
<p>The Vercel ↔ Supabase integration can create these for you. Check the environment scope anyway.</p>
<h2>2. Redirect URLs: Site URL vs allow list</h2>
<p>Supabase has two settings under Authentication → URL Configuration:</p>
<ul>
<li><strong>Site URL</strong>: the default redirect when your code doesn't pass <code>redirectTo</code>, and the base used in confirmation and reset emails. If you leave it at <code>http://localhost:3000</code>, production users land on localhost. That's problem 2 above.</li>
<li><strong>Redirect URLs</strong>: an allow list. Any <code>redirectTo</code> you pass must match one of these.</li>
</ul>
<p>Recommended config:</p>
<ul>
<li>Site URL: <code>https://yourdomain.com</code></li>
<li>Redirect URLs:<ul>
<li><code>http://localhost:3000/**</code></li>
<li><code>https://*-your-team.vercel.app/**</code> (previews; use your team slug so the wildcard isn't too broad)</li>
<li><code>https://yourdomain.com/**</code></li>
</ul>
</li>
</ul>
<h2>3. Build <code>redirectTo</code> from the current deployment</h2>
<p>Don't hardcode the origin. Use a helper that prefers your production URL, falls back to Vercel's per-deployment URL, then to localhost:</p>
<pre><code class="language-ts">export function getURL() {
  let url =
    process.env.NEXT_PUBLIC_SITE_URL ??          // set in Production only
    process.env.NEXT_PUBLIC_VERCEL_URL ??        // set automatically by Vercel
    'http://localhost:3000/';
  url = url.startsWith('http') ? url : `https://${url}`;
  return url.endsWith('/') ? url : `${url}/`;
}

await supabase.auth.signInWithOAuth({
  provider: 'github',
  options: { redirectTo: `${getURL()}auth/callback` },
});
</code></pre>
<p>The key detail: set <code>NEXT_PUBLIC_SITE_URL</code> <strong>only</strong> for Production. If it's set for Preview too, previews redirect to production, which is the most common "it sends me to the live site" complaint. If you want stable preview URLs per branch, use <code>NEXT_PUBLIC_VERCEL_BRANCH_URL</code> instead.</p>
<h2>4. The callback route</h2>
<p>With the App Router and <code>@supabase/ssr</code>, exchange the code server-side:</p>
<pre><code class="language-ts">// app/auth/callback/route.ts
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get('code');
  const next = searchParams.get('next') ?? '/';
  if (code) {
    const supabase = await createClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) return NextResponse.redirect(`${origin}${next}`);
  }
  return NextResponse.redirect(`${origin}/auth/error`);
}
</code></pre>
<p>Use the request's <code>origin</code>, not an env var, so each deployment redirects to itself.</p>
<h2>5. OAuth provider settings</h2>
<p>For Google, GitHub and others, the provider's callback is <strong>Supabase's</strong> URL (<code>https://&lt;project&gt;.supabase.co/auth/v1/callback</code>), not your app's. You don't need to add each Vercel preview URL to Google Cloud Console. Only Supabase's allow list needs the wildcard.</p>
<h2>6. Don't let a rewrite eat your routes</h2>
<p>A catch-all in <code>vercel.json</code> like <code>{ "source": "/(.*)", "destination": "/" }</code> is applied before your route handlers at runtime. The build log looks perfect, but <code>/auth/callback</code> silently serves the homepage. Remove it; the App Router doesn't need it.</p>
<h2>7. Middleware keeps the session alive</h2>
<p>Run the standard <code>@supabase/ssr</code> middleware that calls <code>supabase.auth.getUser()</code> and writes refreshed cookies to both the request and the response. Without it, sessions expire quietly and server components see a logged-out user.</p>
<h2>Pre-launch checklist</h2>
<ul>
<li> Public env vars exist in all three Vercel environments</li>
<li> Service key is server-only</li>
<li> <code>NEXT_PUBLIC_SITE_URL</code> is set in Production only</li>
<li> Supabase Site URL is the production domain</li>
<li> Redirect URLs include localhost, preview wildcard and production</li>
<li> Callback uses the request origin</li>
<li> No catch-all rewrite in <code>vercel.json</code></li>
<li> Middleware refreshes cookies</li>
<li> Email templates use <code>{{ .SiteURL }}</code> / <code>{{ .ConfirmationURL }}</code>, not a hardcoded host</li>
</ul>
<p>If you'd rather start from a project where this wiring is already done, <a href="https://massvai.com">Massvai</a> generates Next.js + Supabase apps with one-click Vercel deploy and gives you the code to inspect. Either way, it's worth knowing what each setting does, because it's the first thing you'll debug when login breaks on a new domain.</p>
]]></content:encoded></item></channel></rss>