First off, this piece is inspired by Brian Lovin in his ”How my website works” article.
Background
I‘ve been redesigning my personal website way too many times than I can recall. I‘ve been doing it since 2012. Some sites were never launched, and some are just under-the-hood changes. These are some of them:
- Octopress (was on Heroku, and I brought it down)
- WordPress
- Github Pages: http://www.ajmalafif.my/
- Jekyll: v3.ajmalafif.com
- Gatsby v2 with Netlify CMS
- and many, many more...
What I do differently than previous iterations
Brian called this ”to reduce friction for iteration and writing”. I can‘t agree hard enough with the statement. That‘s why in this latest iteration, I have Sanity.io as my CMS of choice. I have to say it‘s very impressive; I have tried wiring Gatsby with Ghost, NetlifyCMS, and WordPress. None of them sit right with me and stick. Finally found the one that works for me and I can settle with.
Tech stack
- Gatsby v3
- Tailwind v2 (via twin.macro)
- Sanity
- Netlify
Features
This is a list of features that I have built with the site. Some of these might be “hidden” if you don‘t use or are unfamiliar with them, like RSS feed and Progressive Web App.
- Progressive Web App (PWA)
- RSS Feed
- Dark Mode
- Site Search with Algolia (CMD + K)
- Sitemap (robot.txt)
- MDX & Sanity.io for content sources
- Open Graph image
`gatsby-config.js`
RSS feed
1{2 resolve: `gatsby-plugin-feed`,3 options: {4 query: `5 {6 site {7 siteMetadata {8 title9 description10 siteUrl11 site_url: siteUrl12 }13 }14 }15 `,16 feeds: [17 {18 serialize: ({ query: { site, allSanityPost = [], allSanityReview = [], allMdx } }) => {19 const records = []20
21 allSanityPost.edges22 .filter(({ node }) => filterOutDocsPublishedInTheFuture(node))23 .filter(({ node }) => node.slug)24 .forEach(({ node }) => {25 const { title, publishedAt, slug, _rawBody, _rawExcerpt } = node26 const url = site.siteMetadata.siteUrl + getBlogUrl(slug.current)27 records.push({28 title: title,29 date: publishedAt,30 url,31 guid: url,32 description: PortableText({33 blocks: _rawExcerpt,34 serializers: {35 types: {36 code: ({ node }) =>37 h('pre', h('code', { lang: node.language }.node.code)),38 },39 },40 }),41 custom_elements: [42 {43 'content:encoded': PortableText({44 blocks: _rawBody,45 serializers: {46 types: {47 code: ({ node }) =>48 h('pre', h('code', { lang: node.language }, node.code)),49 mainImage: ({ node }) =>50 h('img', {51 src: imageUrlFor(node.asset).url(),52 }),53 authorReference: ({ node }) => h('p', 'Author: ' + node.author.name),54 },55 },56 }),57 },58 ],59 })60 })61
62 allSanityReview.edges63 .filter(({ node }) => filterOutDocsPublishedInTheFuture(node))64 .filter(({ node }) => node.slug)65 .forEach(({ node }) => {66 const { title, publishedAt, slug, _rawReviews, _rawExcerpt } = node67 const review = _rawReviews[0].reviewContent68 const reviewedAt = _rawReviews[0].reviewedAt69 const url = site.siteMetadata.siteUrl + getReviewUrl(slug.current)70 records.push({71 title: title,72 date: reviewedAt,73 url,74 guid: url,75 description: PortableText({76 blocks: _rawExcerpt,77 serializers: {78 types: {79 code: ({ node }) =>80 h('pre', h('code', { lang: node.language }.node.code)),81 },82 },83 }),84 custom_elements: [85 {86 'content:encoded': PortableText({87 blocks: review,88 serializers: {89 types: {90 code: ({ node }) =>91 h('pre', h('code', { lang: node.language }, node.code)),92 },93 },94 }),95 },96 ],97 })98 })99
100 allMdx.edges.forEach(edge => {101 records.push(102 Object.assign({}, edge.node.frontmatter, {103 description: edge.node.excerpt,104 date: edge.node.frontmatter.date,105 url: site.siteMetadata.siteUrl + edge.node.fields.slug,106 guid: site.siteMetadata.siteUrl + edge.node.fields.slug,107 custom_elements: [{ 'content:encoded': edge.node.html }],108 }),109 )110 })111 records.slice().sort((a, b) => new Date(b.date) - new Date(a.date))112 return records113 },114 query: `115 {116 allSanityPost(sort: { fields: publishedAt, order: DESC }) {117 edges {118 node {119 _rawExcerpt120 _rawBody(resolveReferences: {maxDepth: 10})121 title122 publishedAt123 slug {124 current125 }126 }127 }128 }129 allSanityReview(sort: { fields: publishedAt, order: DESC }) {130 edges {131 node {132 _rawExcerpt133 _rawReviews(resolveReferences: {maxDepth: 10})134 title135 publishedAt136 slug {137 current138 }139 }140 }141 }142 allMdx(143 limit: 1000,144 sort: { order: DESC, fields: [frontmatter___date] },145 filter: { frontmatter: { template: { regex: "/project-post/" } }}146 ) {147 edges {148 node {149 fields {150 slug151 }152 frontmatter {153 title154 date155 dateModified156 }157 html158 }159 }160 }161 }162 `,163 output: '/rss.xml',164 title: 'Ajmal Afif — RSS Feed',165 },166 {167 serialize: ({ query: { site, allMdx } }) => {168 return allMdx.edges.map(edge => {169 return Object.assign({}, edge.node.frontmatter, {170 description: edge.node.excerpt,171 date: edge.node.frontmatter.date,172 url: site.siteMetadata.siteUrl + edge.node.fields.slug,173 guid: site.siteMetadata.siteUrl + edge.node.fields.slug,174 custom_elements: [{ 'content:encoded': edge.node.html }],175 })176 })177 },178 query: `179 {180 allMdx(181 limit: 1000,182 sort: { order: DESC, fields: [frontmatter___date] },183 filter: { frontmatter: { template: { regex: "/project-post/" } }}184 ) {185 edges {186 node {187 fields { slug }188 frontmatter {189 title190 date191 dateModified192 }193 html194 }195 }196 }197 }198 `,199 output: '/projects.xml',200 title: 'Ajmal Afif‘s projects · RSS Feed',201 match: '^/projects/',202 },203 ],204 },205},
PWA
1{2 resolve: `gatsby-plugin-manifest`,3 options: {4 name: `Ajmal Afif`,5 short_name: `Ajmal Afif`,6 start_url: `/`,7 display: `standalone`,8 icon: `src/images/icon.svg`,9 icon_options: {10 purpose: `maskable`,11 },12 theme_color_in_head: false,13 },14},15{16 resolve: `gatsby-plugin-offline`,17 options: {18 precachePages: [`/notes/*`, `/projects/*`, `/work`, `/about`],19 },20},
Algolia Search
1{2 resolve: `gatsby-plugin-algolia`,3 options: {4 appId: process.env.GATSBY_ALGOLIA_APP_ID,5 apiKey: process.env.ALGOLIA_ADMIN_KEY,6 queries: require('./src/utils/algolia-queries'),7 },8},
Sitemap & robots.txt
1'gatsby-plugin-advanced-sitemap',2 {3 resolve: 'gatsby-plugin-robots-txt',4 options: {5 resolveEnv: () => NETLIFY_ENV,6 env: {7 production: {8 policy: [{ userAgent: '*' }],9 sitemap: 'https://ajmalafif.com/sitemap.xml',10 },11 'branch-deploy': {12 policy: [{ userAgent: '*', disallow: ['/'] }],13 sitemap: null,14 host: null,15 },16 'deploy-preview': {17 policy: [{ userAgent: '*', disallow: ['/'] }],18 sitemap: null,19 host: null,20 },21 },22 },23 },
canonical URL
1 {2 resolve: `gatsby-plugin-canonical-urls`,3 options: {4 siteUrl: siteUrl,5 },6 },7
Sanity.io
1{2 resolve: 'gatsby-source-sanity',3 options: {4 ...clientConfig.sanity,5 token: process.env.SANITY_READ_TOKEN,6 watchMode: !isProd,7 overlayDrafts: !isProd,8 },9},10{11 resolve: 'gatsby-plugin-sanity-image',12 options: {13 ...clientConfig.sanity,14
15 // Automatically use `SanityImage.asset.alt` as alt text16 altFieldName: 'alt',17
18 // Recognize custom types that extend SanityImage19 customImageTypes: ['SanityMainImage'],20 },21},
`gatsby-node.js`
Function for `/notes` pages
- sourced from Sanity CMS
- previous/next pagination
- numbered list pagination (that “Goooooogle” like search result pagination)
1// create notes Pages2async function createBlogPostPages(graphql, actions) {3 const { createPage } = actions4 const result = await graphql(`5 {6 allSanityPost(7 filter: { slug: { current: { ne: null } }, publishedAt: { ne: null } }8 sort: { fields: [publishedAt], order: DESC }9 limit: 100010 ) {11 edges {12 node {13 id14 publishedAt15 _updatedAt16 slug {17 current18 }19 title20 }21 }22 }23 }24 `)25
26 if (result.errors) throw result.errors27
28 const postEdges = (result.data.allSanityPost || {}).edges || []29
30 // previous/next pagination31 postEdges32 .filter(edge => !isFuture(new Date(edge.node.publishedAt)))33 .forEach((edge, index) => {34 const previous = index === postEdges.length - 1 ? null : postEdges[index + 1].node35 const next = index === 0 ? null : postEdges[index - 1].node36 const { id, slug = {} } = edge.node37 const path = `/notes/${slug.current}/`38 const updated = edge.node._updatedAt39
40 createPage({41 path,42 component: require.resolve('./src/templates/blog-post.js'),43 context: {44 id,45 previous,46 next,47 updated,48 },49 })50 })51
52 // numbered list pagination53 const postsPerPage = 5054 const numPages = Math.ceil(postEdges.length / postsPerPage)55
56 Array.from({ length: numPages }).forEach((_, i) => {57 createPage({58 path: i === 0 ? `/notes/` : `/notes/${i + 1}/`,59 component: require.resolve('./src/templates/blog-list.js'),60 context: {61 limit: postsPerPage,62 skip: i * postsPerPage,63 numPages,64 currentPage: i + 1,65 },66 })67 })68}69
Function for “article/naked” pages
This function is intended to create “naked” pages like;
1// create articles Pages2async function createArticlePages(graphql, actions) {3 const { createPage } = actions4 const result = await graphql(`5 {6 allSanityArticle(7 filter: { slug: { current: { ne: null } }, publishedAt: { ne: null } }8 sort: { fields: [publishedAt], order: DESC }9 limit: 100010 ) {11 edges {12 node {13 id14 publishedAt15 _updatedAt16 slug {17 current18 }19 title20 }21 }22 }23 }24 `)25
26 if (result.errors) throw result.errors27
28 const postEdges = (result.data.allSanityArticle || {}).edges || []29
30 // previous/next pagination31 postEdges32 .filter(edge => !isFuture(new Date(edge.node.publishedAt)))33 .forEach((edge, index) => {34 const previous = index === postEdges.length - 1 ? null : postEdges[index + 1].node35 const next = index === 0 ? null : postEdges[index - 1].node36 const { id, slug = {} } = edge.node37 const path = `/${slug.current}/`38 const updated = edge.node._updatedAt39
40 createPage({41 path,42 component: require.resolve('./src/templates/article-post.js'),43 context: {44 id,45 previous,46 next,47 updated,48 },49 })50 })51}
Function for “category” pages
A category page template accommodates a list of notes with similar topics or categories. An example of a category/topic page:
1// create category Pages2async function createCategoryPages(graphql, actions) {3 // Get Gatsby‘s method for creating new pages4 const { createPage } = actions5 // Query Gatsby‘s GraphAPI for all the categories that come from Sanity6 // You can query this API on http://localhost:8000/___graphql7 const result = await graphql(`8 {9 allSanityCategory {10 nodes {11 slug {12 current13 }14 id15 }16 }17 }18 `)19 // If there are any errors in the query, cancel the build and tell us20 if (result.errors) throw result.errors21
22 // Let‘s gracefully handle if allSanityCategory is null23 const categoryNodes = (result.data.allSanityCategory || {}).nodes || []24
25 categoryNodes26 // Loop through the category nodes, but don't return anything27 .filter(node => !isFuture(new Date(node.publishedAt)))28 .forEach(node => {29 // Desctructure the id and slug fields for each category30 const { id, slug = {} } = node31 // If there isn't a slug, we want to do nothing32 if (!slug) return33
34 // Make the URL with the current slug35 const path = `/notes/topics/${slug.current}/`36
37 // Create the page using the URL path and the template file, and pass down the id38 // that we can use to query for the right category in the template file39 createPage({40 path,41 component: require.resolve('./src/templates/category.js'),42 context: { id },43 })44 })45}46
My learnings making my own website
I always have trouble saying I am making my website from scratch. I get it when I saw or heard this, but to me, I didn‘t create React or GatsbyJS or Sanity. So technically, I am just wiring and stitching things together to make things work for my use case.
Ask specific questions with a clear outcome
Bonus if you have tried and approached it, breakdown of what worked and what doesn‘t. Half of the time, you got to the answer before you posted your questions
Make your working repo public
I can‘t stress this enough. It is already tricky to debug and help out a “unique” setup that deviates from a basic, common one. Troubleshooting blindly makes it even harder to get help. Making your working repo public will make it easier for others to help you out. In my case, I even had kind peeps send Pull Requests to help.
Thoughts and plans for the next iteration
Gatsby really makes some of the features easier to implement, but the lengthy build time is really a deal-breaker for me.
Although it will be such a steep learning curve to pick up Next or Remix, I think the challenge (and the learning) I‘ll pick up along the way will be worth the pain and effort.
Implement Storybook.js
I am thinking of overengineering these things, you know; a ”mini” Design System starting from tokens from Figma all the way to a kitchen sink with a Storybook for each component.
Introduce Page Transition interaction & animation
I tend to make my website function and present itself rather static, given that most of it is statically generated anyways. Maybe this time, I will explore and leverage that fresh Page Transition API and make the site slicker and snappier.
What‘s next?
Not to sound cheesy, but literally, right now, my instinct on what the next version of the site should be built with, and you guessed it... NextJS!
But why?
To be honest, with my current use case and need, sticking with Gatsby seems convenient. Given that I don‘t code full-time, my Javascript and React are quite limited and aren‘t that advanced too. But...
For my setup Gatsby v3 (and I have a v4 branch) build time on Netlify is painfully slow. I have less than 100 pages, but it takes on average, 7 to 10 minutes for each build. I had a setup years back in Jekyll (with TravisCI) and HarpJS (with surge.sh) that is probably within the same range.
Right now, my deterrence to migrate is that to have the same features mentioned above (RSS Feed, PWA, Algolia powered search, sitemap, and more) are non-trivial, no thanks to my limited JavaScript and NextJS skills.
However, the new Layout RFC that was just announced really triggers that FOMO and design itch yet again. Just imagining the potential of adding subtle enter/exit animation between pages within a layout might be worth the pain of banging my head on the walls.. yet again.
Given this is already the fifth reincarnation of my personal site, I think it‘s a matter of time before I relapse and feed the itch to redo my website all over again 😭.
Reach out to me on Twitter if you have any questions or feedback!