<?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[Najmul Hasan]]></title><description><![CDATA[Engineering notes by Najmul Hasan, Founder & CEO of NAVICORE: Next.js, Supabase, React Native and serverless architecture. najmulhasan.navicore.co]]></description><link>https://najmulhasan.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a4d3c373c2e8c53df3cfbaa/3c17f287-3d60-48d9-960d-d37cd5442d5b.png</url><title>Najmul Hasan</title><link>https://najmulhasan.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 19 Oct 2026 07:18:52 GMT</lastBuildDate><atom:link href="https://najmulhasan.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[GitHub Contents API CMS in Next.js: Publish Markdown Without a Database]]></title><description><![CDATA[You can publish from a browser without running a database: an admin form commits a Markdown file to your GitHub repo, and the host redeploys. This post shows that GitHub Contents API CMS pattern in Ne]]></description><link>https://najmulhasan.hashnode.dev/github-contents-api-cms-nextjs</link><guid isPermaLink="true">https://najmulhasan.hashnode.dev/github-contents-api-cms-nextjs</guid><category><![CDATA[Next.js]]></category><category><![CDATA[GitHub]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[markdown]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[najmulhasan]]></dc:creator><pubDate>Fri, 16 Oct 2026 17:03:35 GMT</pubDate><content:encoded><![CDATA[<p>You can publish from a browser without running a database: an admin form commits a Markdown file to your GitHub repo, and the host redeploys. This post shows that GitHub Contents API CMS pattern in Next.js 16 (App Router), the one rule that trips people up (<code>sha</code>), and how to handle a 409.</p>
<p>Status first: I built this admin and ran 41 API checks against mock GitHub, dev.to and IndexNow servers. I have not run it against the live services yet. Treat the code below as a pattern tested against mocks, not a battle-tested one.</p>
<h2>The short answer</h2>
<p>Commit with one request:</p>
<pre><code class="language-plaintext">PUT /repos/{owner}/{repo}/contents/{path}
</code></pre>
<p>Send <code>message</code>, <code>content</code> (the file, base64-encoded) and <code>branch</code>. For an update, also send the file's current <code>sha</code>. A new file needs no <code>sha</code>. A stale <code>sha</code> returns 409. Show that to the editor instead of retrying blindly. After the commit, Vercel redeploys the site.</p>
<p>You also get git history, diffs and rollback for every edit, because the content is just files in the repo.</p>
<h2>Set up the GitHub Contents API as a CMS</h2>
<p>Articles are Markdown files in <code>content/articles/&lt;slug&gt;.md</code>. The admin runs on the server side of the same Next.js app, so the GitHub token never reaches the browser.</p>
<p>Environment variables (names only):</p>
<pre><code class="language-plaintext">GITHUB_TOKEN
GITHUB_OWNER
GITHUB_REPO
GITHUB_BRANCH
SITE_ORIGIN
</code></pre>
<p>Use a fine-grained personal access token limited to this one repository, with Contents set to read and write (as of October 2026, verify the permission name in GitHub's settings). Never prefix it with <code>NEXT_PUBLIC_</code>.</p>
<h2>Read and write a file</h2>
<p>This is a minimal version written for Next.js 16. It is server-only code.</p>
<pre><code class="language-ts">// lib/github-content.ts
const OWNER = process.env.GITHUB_OWNER;
const REPO = process.env.GITHUB_REPO;
const BRANCH = process.env.GITHUB_BRANCH || "main";
const base = `https://api.github.com/repos/${OWNER}/${REPO}/contents`;

function ghHeaders() {
  return {
    Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
    Accept: "application/vnd.github+json",
    "X-GitHub-Api-Version": "2022-11-28",
    "Content-Type": "application/json",
  };
}

export function isValidSlug(slug: unknown) {
  return typeof slug === "string" &amp;&amp; /^[a-z0-9-]{1,80}$/.test(slug);
}

export async function readArticle(slug: string) {
  const res = await fetch(
    `${base}/content/articles/${slug}.md?ref=${BRANCH}`,
    { headers: ghHeaders(), cache: "no-store" }
  );
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`GitHub read failed: ${res.status}`);
  const data = await res.json();
  const markdown = Buffer.from(data.content, "base64").toString("utf8");
  return { markdown, sha: data.sha };
}

export async function writeArticle(slug: string, markdown: string, sha?: string) {
  const res = await fetch(`${base}/content/articles/${slug}.md`, {
    method: "PUT",
    headers: ghHeaders(),
    body: JSON.stringify({
      message: sha ? `Update article: ${slug}` : `Add article: ${slug}`,
      content: Buffer.from(markdown, "utf8").toString("base64"),
      branch: BRANCH,
      sha,
    }),
  });
  if (res.status === 409) return { conflict: true, sha: null };
  if (!res.ok) throw new Error(`GitHub write failed: ${res.status}`);
  const data = await res.json();
  return { conflict: false, sha: data.content.sha };
}
</code></pre>
<p><code>JSON.stringify</code> drops <code>sha</code> when it is <code>undefined</code>, so one function handles both create and update. Validate the slug before it becomes part of a path. That regex is what stops <code>../</code> tricks.</p>
<h2>The route handler</h2>
<pre><code class="language-ts">// app/api/admin/articles/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { verifySession } from "@/lib/session"; // your own signed-cookie check, not shown
import { isValidSlug, writeArticle } from "@/lib/github-content";

export async function PUT(req: Request) {
  if (req.headers.get("origin") !== process.env.SITE_ORIGIN) {
    return NextResponse.json({ error: "Bad origin" }, { status: 403 });
  }
  const store = await cookies();
  if (!verifySession(store.get("admin_session")?.value)) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { slug, markdown, sha } = await req.json();
  if (!isValidSlug(slug) || typeof markdown !== "string" || markdown === "") {
    return NextResponse.json({ error: "Invalid input" }, { status: 400 });
  }

  try {
    const result = await writeArticle(slug, markdown, sha);
    if (result.conflict) {
      return NextResponse.json(
        { error: "File changed on GitHub. Reload before saving." },
        { status: 409 }
      );
    }
    return NextResponse.json({ sha: result.sha });
  } catch {
    return NextResponse.json({ error: "Publish failed" }, { status: 502 });
  }
}
</code></pre>
<p>In Next.js 15 and later, <code>cookies()</code> is async, hence the <code>await</code>. This route has no dynamic segment. If you add one, <code>params</code> is a Promise too. Next.js 14 is sync for both.</p>
<h2>The sha rule and the 409</h2>
<p>Updating a file needs the blob <code>sha</code> of the version you are replacing. Get it from a read (<code>GET .../contents/{path}</code>) and send it back with the edit.</p>
<p>Two details cause most bugs:</p>
<ul>
<li><p><strong>Keep the new sha.</strong> A successful PUT returns the new file <code>sha</code> in <code>content.sha</code>. Store it in the editor's state. If you keep sending the old one, the second save fails.</p>
</li>
<li><p><strong>Do not auto-retry a 409.</strong> It means the file (or the branch) changed since you read it, for example from a second tab or a direct commit. Fetching a fresh <code>sha</code> and resending would silently overwrite that change. Return the 409 and let the editor reload.</p>
</li>
</ul>
<p>Browser side:</p>
<pre><code class="language-ts">const res = await fetch("/api/admin/articles", {
  method: "PUT",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ slug, markdown, sha }),
});
if (res.status === 409) {
  // tell the editor to reload
} else if (res.ok) {
  const data = await res.json();
  sha = data.sha; // use this on the next save
}
</code></pre>
<h2>Pitfalls</h2>
<ul>
<li><p><strong>Every save is a commit, and every commit can trigger a deploy.</strong> Keep <code>draft: true</code> in the front matter until the article is ready, and save less often.</p>
</li>
<li><p><strong>UTF-8 and base64.</strong> Encode on the server with <code>Buffer</code>. In the browser, <code>btoa</code> throws on characters outside Latin-1, which includes Bangla text.</p>
</li>
<li><p><strong>Write one file at a time.</strong> Parallel writes to the same branch can conflict. GitHub advises serial requests (as of October 2026, verify in the REST docs).</p>
</li>
<li><p><strong>Branch protection.</strong> If the target branch requires pull requests, the direct commit is rejected. Commit to a branch that allows it.</p>
</li>
<li><p><strong>Large files.</strong> The Contents API has size limits. Keep articles as text and store images elsewhere (as of October 2026, verify the current limits).</p>
</li>
<li><p><strong>A missing</strong> <code>sha</code> <strong>on an existing file</strong> is rejected with a validation error, not a 409 (as of October 2026, verify the exact status code).</p>
</li>
<li><p><strong>Protect the login route.</strong> Anyone who gets past the admin password can commit to your repo. Put a rate limit in front of it, as in <a href="https://najmulhasan.navicore.co/blog/nextjs-rate-limiting-upstash-redis-serverless">Next.js rate limiting with Upstash Redis</a>.</p>
</li>
<li><p><strong>Not tested live.</strong> Everything above passed against mock servers only. Run one real commit to a scratch branch before you rely on it.</p>
</li>
</ul>
<h2>Summary</h2>
<ul>
<li><p>One <code>PUT /repos/{owner}/{repo}/contents/{path}</code> call turns a browser form into a commit, and the host redeploys.</p>
</li>
<li><p>New files need no <code>sha</code>. Updates need the current <code>sha</code>, and you must store the new one after each save.</p>
</li>
<li><p>Treat 409 as "someone changed this". Show it, do not retry blindly.</p>
</li>
<li><p>Keep the token server-side and scoped to one repo. Validate the slug before building the path.</p>
</li>
</ul>
<p>Next: if you want a 6-digit code login for an admin or app instead of a password, here is the Supabase version. <a href="https://najmulhasan.navicore.co/blog/supabase-email-otp-nextjs-6-digit-code">Supabase Email OTP in Next.js</a>.</p>
<hr />
<p><em>Originally published at</em> <a href="https://najmulhasan.navicore.co/blog/github-contents-api-cms-in-next-js-publish-markdown-without-a-database"><em>najmulhasan.navicore.co/blog/github-contents-api-cms-in-next-js-publish-markdown-without-a-database</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Supabase Email OTP in Next.js: 6-Digit Codes Instead of Magic Links]]></title><description><![CDATA[Magic links are the default in Supabase email sign-in, and they break in exactly the places you don't test: in-app browsers, email clients that pre-fetch links, and mobile apps where the link opens yo]]></description><link>https://najmulhasan.hashnode.dev/supabase-email-otp-nextjs-6-digit-code</link><guid isPermaLink="true">https://najmulhasan.hashnode.dev/supabase-email-otp-nextjs-6-digit-code</guid><category><![CDATA[TypeScript]]></category><category><![CDATA[authentication]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[supabase]]></category><dc:creator><![CDATA[najmulhasan]]></dc:creator><pubDate>Fri, 16 Oct 2026 03:22:02 GMT</pubDate><content:encoded><![CDATA[<p>Magic links are the default in Supabase email sign-in, and they break in exactly the places you don't test: in-app browsers, email clients that pre-fetch links, and mobile apps where the link opens your website instead of your app. In RailMate, an email sign-in link opening the website instead of the mobile app was one of the open issues in a pre-beta checklist. A 6-digit code avoids deep links entirely: the user reads it in their mail app and types it where they started. Here is how to implement Supabase email OTP in Next.js.</p>
<h2>The short answer</h2>
<p>Change the email template to print <code>{{ .Token }}</code>, call <code>signInWithOtp</code> to send the code, then call <code>verifyOtp</code> with <code>type: "email"</code> to create the session. Rate-limit verification per email address.</p>
<h2>1. Change the email template</h2>
<p>In the Supabase dashboard go to <strong>Authentication → Email Templates → Magic Link</strong> and replace the link with the token:</p>
<pre><code class="language-html">&lt;h2&gt;Your sign-in code&lt;/h2&gt;
&lt;p&gt;Enter this code to continue:&lt;/p&gt;
&lt;p style="font-size:28px;letter-spacing:6px"&gt;&lt;strong&gt;{{ .Token }}&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It expires soon. If you didn't request it, ignore this email.&lt;/p&gt;
</code></pre>
<p>If the template still contains <code>{{ .ConfirmationURL }}</code>, users receive a link and the code flow looks broken. Also configure custom SMTP (Resend, Postmark, SES). The default sender is intended for testing and is heavily rate-limited.</p>
<h2>2. A server client that handles cookies</h2>
<pre><code class="language-ts">// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export function createClient() {
  const cookieStore = cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () =&gt; cookieStore.getAll(),
        setAll(list) {
          try {
            list.forEach(({ name, value, options }) =&gt; cookieStore.set(name, value, options));
          } catch {
            // Called from a Server Component; middleware refreshes the session instead.
          }
        },
      },
    },
  );
}
</code></pre>
<h2>3. Send the code</h2>
<pre><code class="language-ts">// app/api/auth/send-otp/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";
import { clientIp, limiters } from "@/lib/rate-limit";

const Body = z.object({ email: z.string().email().max(254) });

export async function POST(req: Request) {
  const ip = await limiters.otpSend.limit(clientIp(req));
  if (!ip.success) return NextResponse.json({ error: { code: "RATE_LIMIT_EXCEEDED" } }, { status: 429 });

  const parsed = Body.safeParse(await req.json().catch(() =&gt; null));
  if (!parsed.success) return NextResponse.json({ error: { code: "INVALID_EMAIL" } }, { status: 400 });

  const supabase = createClient();
  const { error } = await supabase.auth.signInWithOtp({
    email: parsed.data.email.toLowerCase(),
    options: { shouldCreateUser: true },
  });
  if (error) console.error("send-otp", error.message);

  // Same response whether or not the email exists or sending failed: no account enumeration.
  return NextResponse.json({ ok: true });
}
</code></pre>
<h2>4. Verify the code</h2>
<pre><code class="language-ts">// app/api/auth/verify-otp/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";
import { limiters } from "@/lib/rate-limit";

const Body = z.object({
  email: z.string().email().max(254),
  token: z.string().regex(/^\d{6}$/),
});

export async function POST(req: Request) {
  const parsed = Body.safeParse(await req.json().catch(() =&gt; null));
  if (!parsed.success) return NextResponse.json({ error: { code: "INVALID_CODE" } }, { status: 400 });

  const email = parsed.data.email.toLowerCase();
  const rl = await limiters.otpVerify.limit(email); // keyed by email, not IP
  if (!rl.success) return NextResponse.json({ error: { code: "RATE_LIMIT_EXCEEDED" } }, { status: 429 });

  const supabase = createClient();
  const { error } = await supabase.auth.verifyOtp({ email, token: parsed.data.token, type: "email" });
  if (error) return NextResponse.json({ error: { code: "INVALID_CODE" } }, { status: 401 });

  return NextResponse.json({ ok: true }); // session cookies were set by the server client
}
</code></pre>
<p>Key the verify limiter on the email. An attacker can rotate IPs for free but can't change the account they're targeting. The limiter implementation and the numbers are in the <a href="https://najmulhasan.navicore.co/blog/nextjs-rate-limiting-upstash-redis-serverless">Upstash rate limiting guide</a>.</p>
<h2>5. Keep the session fresh with middleware</h2>
<pre><code class="language-ts">// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({ request });
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () =&gt; request.cookies.getAll(),
        setAll(list) {
          list.forEach(({ name, value }) =&gt; request.cookies.set(name, value));
          response = NextResponse.next({ request });
          list.forEach(({ name, value, options }) =&gt; response.cookies.set(name, value, options));
        },
      },
    },
  );
  await supabase.auth.getUser(); // refreshes the token if needed
  return response;
}

export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
</code></pre>
<h2>Pitfalls</h2>
<ul>
<li><p><strong>Users get a link, not a code.</strong> The template still contains <code>{{ .ConfirmationURL }}</code>.</p>
</li>
<li><p><strong>Account enumeration.</strong> Returning "no such user" from the send endpoint lets anyone test which emails are registered. Always return <code>{ ok: true }</code>.</p>
</li>
<li><p><strong>Unlimited guesses.</strong> A 6-digit code has 1,000,000 values; without a verify limit a fast script covers a large share of them within the code's lifetime. Five attempts per 10 minutes per email caps it at 30 guesses an hour.</p>
</li>
<li><p><strong>Trusting the client.</strong> Use <code>getUser()</code> on the server for authorization decisions, not the session object read from cookies.</p>
</li>
<li><p><strong>Phone OTP is a separate problem.</strong> SMS providers have their own restrictions; for instance, trial tiers can limit delivery to verified numbers, which blocks real users in testing and launch.</p>
</li>
</ul>
<h2>Summary</h2>
<ul>
<li><p>Template <code>{{ .Token }}</code> → <code>signInWithOtp</code> → <code>verifyOtp({ type: "email" })</code>.</p>
</li>
<li><p>Same response for every send request; rate-limit by IP on send and by email on verify.</p>
</li>
<li><p>Run middleware so sessions refresh, and authorize with <code>getUser()</code>.</p>
</li>
</ul>
<hr />
<p><em>Originally published at</em> <a href="https://najmulhasan.navicore.co/blog/supabase-email-otp-nextjs-6-digit-code"><em>najmulhasan.navicore.co/blog/supabase-email-otp-nextjs-6-digit-code</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>