In the spirit of over-complicating the hell out of my personal website, I spent time this weekend trying to solve one very small and seemingly-simple problem: can I make my statically-generated website know when I am viewing it?
I have a bookmarks page where I store helpful links. To add new links, I set up a workflow where I can text myself a url from anywhere. Here's the code to do this. New links get stored in Firebase, which triggers a cloud function to populate metadata for the url by scraping the web page. Here's the cloud function to do this. This flow is really great for saving links while I'm away from my computer.
But, when I'm on my laptop, two problems emerge:
https://brianlovin.com/bookmarks
and paste a link.<title>
tags like {actually useful content about the page} · Site Name
and I don't want the Site Name
included in my bookmarks list.So what I want is:
/bookmarks
, determine if I am the one viewing the page.The hiccups came when I tried to figure out how this should work with GraphQL (which I use on the backend to stitch together multiple third party API services - see code) and Next.js's recently-release Static Site Generation feature.
/bookmarks
route is statically generated at build time. This means that every initial page view will assume an unauthenticated render. So I'll need to check for authentication after the JavaScript rehydrates the client.users
record. This functionality is just for me. Firebase's authentication implementation was a pain, so I abandoned that path in favor of simple cookie authentication.First, some useful information that I dug up through while working on this problem:
cookies
object to the http request
.cookie
helper function to all response
objects in the backend. This will be used to set and nullify cookies.The client side of this project ended up being quite complex. Remember:
/bookmarks
should be statically generated at build time, always rendering a "logged out" view./bookmarks
is loaded, it needs to mount with a pre-populated ApolloProvider
cache to have access to the mutation and query hooks that come with @apollo/client
. Fortunately, I found this comment in the Next.js discussion forum which explains how to implement a withApollo
higher-order component that can instantiate itself with props from the static build phase.
I made some small modifications, but you can see the implementation here.
Next, we need to instantiate an ApolloClient
during build time in getStaticProps
:
// graphql/api/index.ts
const CLIENT_URL =
process.env.NODE_ENV === 'production'
? 'https://brianlovin.com'
: 'http://localhost:3000'
const endpoint = `${CLIENT_URL}/api/graphql`
const link = new HttpLink({ uri: endpoint })
const cache = new InMemoryCache()
export async function getStaticApolloClient() {
return new ApolloClient({
link,
cache,
})
}
Now, in any of our page routes we can use Apollo to fetch data at build time:
// pages/bookmarks.tsx
import { getStaticApolloClient } from '~/graphql/api'
import { gql } from '@apollo/client'
// ... component up here, detailed later
const GET_BOOKMARKS = gql`
query GetBookmarks {
bookmarks {
id
title
url
}
}
`
export async function getStaticProps() {
const client = await getStaticApolloClient()
await client.query({ query: GET_BOOKMARKS })
return {
props: {
// this hydrates the clientside Apollo cache in the `withApollo` HOC
apolloStaticCache: client.cache.extract(),
},
}
}
Because I'll want to add new links to my bookmarks from many devices, I'll need some way to programmatically set a cookie in the browser by "logging in."
The flow should be:
login
mutationlogin
mutation resolver decides whether or not the password is correct. If it isn't, it rejects the request. If the password is correct, it sets a cookie on the response header and returns true
. Before I can do any of this, I'll need to ensure that my GraphQL mutations have access to cookies and a response
object. We can add this information to the GraphQL context object in the server constructor:
// pages/api/graphql/index.ts
// https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
import cookies from './path/to/cookieHelper'
import typeDefs from './path/to/typeDefs'
import resolvers from './path/to/resolvers'
import { ApolloServer } from 'apollo-server-micro'
function isAuthenticated(req) {
// I use a cookie called 'session'
const { session } = req?.cookies
// Cryptr requires a minimum length of 32 for any signing
if (!session || session.length < 32) {
return false
}
const secret = process.env.PASSWORD_TOKEN
const validated = process.env.PASSWORD
const cryptr = new Cryptr(secret)
const decrypted = cryptr.decrypt(session)
return decrypted === validated
}
function context(ctx) {
return {
// expose the cookie helper in the GraphQL context object
cookie: ctx.res.cookie,
// allow queries and mutations to look for an `isMe` boolean in the context object
isMe: isAuthenticated(ctx.req),
}
}
const apolloServer = new ApolloServer({
typeDefs,
resolvers,
context,
})
export const config = {
api: {
bodyParser: false, // required for Next.js to play nicely with GraphQL request bodies
},
}
const handler = apolloServer.createHandler({ path: '/api/graphql' })
// attach cookie helpers to all response objects
export default cookies(handler)
The mutation:
// graphql/mutations/auth.ts
import { gql } from '@apollo/client'
export const LOGIN = gql`
mutation login($password: String!) {
login(password: $password)
}
`
The resolver:
// graphql/resolvers/mutations/login.ts
import Cryptr from 'cryptr'
export function login(_, { password }, ctx) {
const { cookie } = ctx
const validator = process.env.PASSWORD
if (password !== validator) return false
const secret = process.env.PASSWORD_TOKEN
const cryptr = new Cryptr(secret)
const encrypted = cryptr.encrypt(password)
// the password is correct, set a cookie on the response
cookie('session', encrypted, {
// cookie is valid for all subpaths of my domain
path: '/',
// this cookie won't be readable by the browser
httpOnly: true,
// and won't be usable outside of my domain
sameSite: 'strict',
})
// tell the mutation that login was successful
return true
}
Next, let's log in from the client:
// pages/login.tsx
import * as React from 'react'
import { useRouter } from 'next/router'
import { useMutation } from '@apollo/client'
import { LOGIN } from '~/graphql/mutations/auth.ts'
import { withApollo } from '~/components/withApollo'
function Login() {
const router = useRouter()
const [password, setPassword] = React.useState('')
const [handleLogin] = useMutation(LOGIN, {
variables: { password },
onCompleted: (data) => data.login && router.push('/'),
})
function onSubmit(e) {
e.preventDefault()
handleLogin()
}
return (
<form onSubmit={onSubmit}>
<input
type="password"
placeholder="password"
onChange={(e) => setPassword(e.target.value)}
/>
</form>
)
}
// remember that withApollo wraps our component in an ApolloProvider, giving us access to use the `useMutation` and `useQuery` hooks in our component.
export default withApollo(Login)
So our flow should now work:
Okay, so now I have a signed cookie on my browser which will be used in all future requests to verify my identity. The next step is provide the client with some kind of isMe
boolean that can be fetched from anywhere. We can write a small GraphQL mutation to provide this information:
// graphql/queries/isMe.ts
import { gql } from '@apollo/client'
export const IS_ME = gql`
query IsMe {
isMe
}
`
Remember, we've already written an isMe
helper into our GraphQL context object, so we can return that value in our resolver:
// graphql/resolvers/isMe.ts
export function isMe(_, __, { isMe }) {
return isMe
}
Next, let's write our GraphQL query on the client to find out if it's me viewing the page:
// src/hooks/useAuth.tsx
import { IS_ME } from '~/graphql/queries/isMe.ts'
import { useQuery } from '@apollo/client'
export function useAuth() {
const { data } = useQuery(IS_ME)
return {
isMe: data && data.isMe,
}
}
With this helper hook, we can now start checking for isMe
anywhere in the client:
// src/pages/bookmarks.tsx
import * as React from 'react'
import { useQuery } from '@apollo/client'
import BookmarksList from '~/components/Bookmarks'
import { GET_BOOKMARKS } from '~/graphql/queries'
import { useAuth } from '~/hooks/useAuth'
import { getStaticApolloClient } from '~/graphql/api'
import { withApollo } from '~/components/withApollo'
import AddBookmark from '~/components/AddBookmark'
function Bookmarks() {
// cache-and network is used because after I add a new bookmark, other people will still be seeing the statically-served HTML created at build time. In this way, the user will see a page rendered _instantly_, and the client will kick off a network request to ensure it has the latest bookmarks data.
const { data } = useQuery(GET_BOOKMARKS, { fetchPolicy: 'cache-and-network' })
const { bookmarks } = data
const { isMe } = useAuth()
return (
<div>
<h1>Bookmarks</h1>
{isMe && <AddBookmark />}
{bookmarks && <BookmarksList bookmarks={bookmarks} />}
</div>
)
}
export async function getStaticProps() {
const client = await getStaticApolloClient()
await client.query({ query: GET_BOOKMARKS })
return {
props: {
apolloStaticCache: client.cache.extract(),
},
}
}
export default withApollo(Bookmarks)
Okay, so now I can progressively disclose UI on the client once the site knows it's me. But because my GraphQL endpoint is exposed to the internet, we'll need to make sure that random people can't write their own POST
s to maliciously save bookmarks.
Here's the mutation resolver on the backend checking the isMe
flag set in the context object, some input validation, and then persisting the bookmark.
// graphql/resolvers/mutations/bookmarks.ts
import { URL } from 'url'
import { AuthenticationError, UserInputError } from 'apollo-server-micro'
import firebase from '~/graphql/api/firebase'
import getBookmarkMetaData from './getBookmarkMetaData'
function isValidUrl(string) {
try {
new URL(string)
return true
} catch (err) {
return false
}
}
export async function addBookmark(_, { url }, { isMe }) {
if (!isMe) throw new AuthenticationError('You must be logged in')
if (!isValidUrl(url)) throw new UserInputError('URL was invalid')
const metadata = await getBookmarkMetaData(url)
const id = await firebase
.collection('bookmarks')
.add({
createdAt: new Date(),
...metadata,
})
.then(({ id }) => id)
return await firebase
.collection('bookmarks')
.doc(id)
.get()
.then((doc) => doc.data())
.then((res) => ({ ...res, id }))
}
This is all a bit...complicated, to say the least. But when it all works, it actually works quite well! And as I incrementally add more mutation types, it should all Just Work™.
At the end of the day, the site gets all the benefits of super-fast initial page loads thanks to static generation at build time, with all the downstream client side functionality of a regular React application.
I hope the pseudocode above will help unblock anyone that is following a similar path as me, but just in case, here's the full pull request containing all the changes that eventually made this work. You'll notice I spent some time hacking in automatic type generation and hook generation using GraphQL Code Generator, and added some polish to the overall experience (like a /logout
page which clears the cookie, in case I'm on a device I don't own).
Please don't hesitate to reach out with questions, I'd love to help! Otherwise, the Next.js discussions have been a fantastic resource for finding solutions to a lot of common problems.
Good luck!
A small favor
Was anything I wrote confusing, outdated, or incorrect? Please let me know! Just write a few words below and I’ll be sure to amend this post with your suggestions.
The email newsletter
Get updates about new posts, new projects, or other meaningful updates to this site delivered to your inbox. Alternatively, you can follow me on Twitter.