~ 6 min read
Publishing Dev Tips with Next.js, Supabase Auth and Notion
I store a list of interesting coding tips that are new to me throughout my working day within Notion. The idea is that if any of them are helpful enough I can go ahead and turn them into actual blog posts when I’m not deep in the midst of some problem.
Putting them in Notion mean’s it’s easy to access from any of my machines when noting them down, but not that accessible when I come to use them. I’ve previously written scripts that pluck the posts and write them out to my local disk in markdown based on if tags I’ve used as metadata.
I decided what might be nicer though is a complete Next.js app to pick the posts I want to publish, preview them in the browser and download in a markdown format for me to use in my Astro blog here. It sounds pretty convoluted, but Supabase Auth makes it super simple to do this.
This post was written as part of Supabase’s content storm - go check it out for all the other exciting work people have done.
Defining the Integration
First in notion we need to define our integration along with it’s capabilities. Supabase has some good docs on how to set this up so I won’t repeat them here. The only notable difference is I want to read content as well, so I need to make sure I also add that as a capability when defining things in Notion.
Handling Sign-In in Next.js
Supabase makes the actual log in very simple with Next.js by providing auth helpers we can drop straight in.
You can see in my Login Form code below that I’m using a fairly generic method that’s able to cope with calls to multiple providers and that I also give the option for signing in with Google. I’m using shadcn/ui to style components in my form.
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { toast } from "@/components/ui/use-toast"
import { Icons } from "@/components/icons"
import type { Database } from '@/lib/database.types'
import type { Session } from '@supabase/auth-helpers-nextjs'
export default function LoginForm({ session }: { session: Session | null }) {
const [isLoading, setIsLoading] = useState<boolean>(false)
const supabase = createClientComponentClient<Database>()
const handleSignInWithSocial = async(provider: any) => {
setIsLoading(true)
const {data, error} = await supabase.auth.signInWithOAuth({
provider: provider,
options: {
redirectTo: `${location.origin}/auth/callback`
}
})
setIsLoading(false)
if (error) {
return toast({
title: "Something went wrong.",
description: "Your sign in request failed. Please try again.",
variant: "destructive",
})
}
}
return (
<div className="grid gap-6">
...
<Button variant="outline" type="button" disabled={isLoading} onClick={() => handleSignInWithSocial('notion')}>
{isLoading ? (
<Icons.spinner className="mr-2 h-4 w-4 animate-spin" />
) : (
<Icons.google className="mr-2 h-4 w-4" />
)}{" "}
Notion
</Button>
</div>
)
}
When added to a page under app/(auth)/login this is what that ends up looking like. You should ignore the alternative sign in methods shown in this image, they’re redundant since they won’t provide access to the notion data I want to access.
Saving the Provider Token
I also need a way of talking with notion within my app. By default Supabase kindly hides away the complexity of auth but I want to be able to refer back to the open id token notion provides to Supabase. To do that I need to intercept the callback coming from notion and store it in a table.
I’ve defined a profiles table that stores the provider tokens against the supabase user id. In my Next.js auth/callback/route.ts I define a route handler like so:
import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import type { Database } from '@/lib/database.types'
export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
if (code) {
const supabase = createRouteHandlerClient<Database>({ cookies })
supabase.auth.onAuthStateChange(async (event, session) => {
if(session?.provider_token) {
await supabase.from('profiles').upsert({
user_id: session.user.id,
provider_token: session.provider_token
})
}
})
await supabase.auth.exchangeCodeForSession(code)
}
// URL to redirect to after sign in process completes
return NextResponse.redirect(requestUrl.origin)
}
You can see that I’m looking to see if the session provider_token exists when state changes and store it along with the user id to the profiles table. We use upsert to always update the token to be the latest value from notion. Now we have access to this from any of our app pages.
Turning Notion Pages into Markdown
The final piece of the puzzle is taking the tips stored in notion and turning them into something able to be previewed and later downloaded. For that I use the official notion client and notion-to-md to take a notion page id and turn it into a string. Notions underlying block structure is really quite confusing to work with if you just want to grab a full page - I’d definitely recommend a library for this. I have a server action “getNotionPage” defined in my actions.tsx which handles this.
const { Client } = require("@notionhq/client")
export async function getNotionPage(target_page_id: string) {
const {supabase, session} = await getSupabase()
const { data: profile } = await supabase
.from("profiles")
.select("provider_token")
.eq("user_id", session?.user?.id)
.single()
const notion = new Client({
auth: profile?.provider_token as string,
})
const n2m = new NotionToMarkdown({
notionClient: notion
})
const mdblocks = await n2m.pageToMarkdown(target_page_id);
const mdString = n2m.toMarkdownString(mdblocks);
return {
title: "Import Preview",
markdown: mdString.parent
}
}
To render a preview the data, I’m using next-mdx-remote. Under an import/[id]/page.tsx I define a PreviewPage that displays the page preview using the string returned by getNotionPage:
...
import { getNotionPage } from "@/app/actions"
import { MDXRemote } from 'next-mdx-remote/rsc'
export default async function PreviewPage({
params: { id },
}: {
params: { id: string };
}) {
const notImported = await getNotionPage(id)
return (
<div className="hidden flex-col md:flex">
<div className="flex-1">
<div className="prose" >
<h1>{notImported.title}</h1>
<MDXRemote source={ notImported.markdown } options={options} />
</div>
</div>
</div>
);
}
Here’s how all that looks when rendered in the app frontend. The rest of the app is navigating through the notion db structure that stores these pages to find them prior to preview.
Conclusion
Being able to take advantage of Supabase Auth without necessarily making extensive use of the database is really useful. We have a tiny profiles table that only requires a couple of fields to navigate and download the markdown I need.
To take this one step further I could have my app automatically open a commit to the chosen page to my blogs Astro repo, which gets automatically rebuilt and published. That way I’d write my content in notion, select the tip I wanted, mark it as published and it need never touch my machine.