Nye nettsider: Next.js, Sanity og Tailwind CSS – en perfekt kombo?

Nettsidene har selvfølgelig støtte for "dark mode".

Mine gamle nettsider var laget med Gatsby og Sanity, og selv om de fungerte utmerket var de forholdsvis enkle og etter min mening ikke spesielt fine å se på. De var basert på en ferdig mal fra Sanity – uten særlig tilpasning. Det har også gått noen år siden nettsidene ble til, og i mellomtiden har jeg lært veldig mye. Derfor ville jeg lage nye nettsider helt fra "scratch" og i minst mulig grad basere meg på ferdige maler.

Da de gamle nettsidene ble laget var Gatsby på vei på full fart opp, og jeg vurderte å basere også de nye nettsidene på Gatsby. Men populariteten til dette rammeverket har falt en del, samtidig som Next.js har fått et veldig oppsving. Mye av drivkraften bak mine hobbyprosjekter er at jeg vil lære noe nytt, og ettersom jeg ennå ikke helt har gitt opp drømmen om å en dag jobbe på heltid med web- og applikasjonsutvikling forsøker jeg hele tiden å lære meg de teknologiene jeg vet det er størst etterspørsel etter.

Til slutt falt valget på denne teknologistacken:

  • Next.js – et fantastisk React-rammeverk som kan bygge både statiske og serverside-rendrede nettsider. Terskelen for å lære Next.js var lav, ettersom det er mange likheter med Gatsby.
  • TypeScript. Jeg har i det siste begynt å bruke TypeScript i de fleste nye prosjekter for å redusere risikoen for bugs og på grunn av den veldig gode utvikleropplevelsen det gir i VS Code. Og fordi det er populært ute i "den virkelige verden", og dermed noe jeg ønsker å beherske.
  • Tailwind CSS. Tailwind CSS står på listen over ting jeg ønsket å beherske godt, så derfor falt valget på det (jeg har ikke angret). Jeg vurderte også å bruke Chakra UI, et ferdig komponentbibliotek jeg brukte da jeg bygget nettsider for sameiet der jeg bor. Det har sin egen måte å style komponenter på som gjør at man trenger minimalt med CSS.
  • Sanity.io. De gamle nettsidene brukte også Sanity, så det var naturlig å fortsette med dette utmerkede headless CMS-systemet som er laget i Norge. Jeg har også erfaring med et par andre løsninger, som Contentful og Headless Wordpress, men kommer stadig vekk tilbake til Sanity – rett og slett fordi det er innmari bra.
  • Vercel for deploying av de ferdige nettsidene.
  • GSAP for noen enkle scroll-animasjoner.
  • Plausible.io for trafikkanalyse
  • Sentry for feilrapportering.

Etter å ha skisset opp nettsidene i Figma, begynte jeg å kode. Fokus hele veien har vært å basere meg på "best practice", og jeg har prøvd å lage så mange gjenbrukbare komponenter som mulig for å gjøre jobben enklere for meg selv neste gang jeg skal lage en nettside. Ved å basere meg på temaer for CSS-styling og bruke CSS-variabler (custom properties) for farger etc., er målet at det skal gå raskt å bygge andre nettsider senere med denne som utgangspunkt.

Sanity Studio for innholdsredigering og Next-nettsidene ligger i et monorepo basert på Turborepo. Dette er også gjort for å gjøre det enkelt å dele komponenter, konfigurasjoner, ESlint-regler og annet mellom ulike prosjekter senere.

Kildekoden til de nye nettsidene ligger på min Github her

Sette opp Next.js og Sanity.io

Jeg hadde allerede mye innhold fra de gamle nettsidene som jeg ville ta vare på, og satte derfor opp de nye Next-sidene slik at de hentet data fra det samme Sanity-datasettet som jeg hadde brukt for de gamle nettsidene. Folkene i Sanity har laget en utmerket guide til hvordan du setter opp Next-nettsider som henter data fra Sanity, så det er neppe noen grunn for meg til å gjenta dette her. De har også en rekke ferdige starter-prosjekter som du kan bruke som utgangspunkt.

Jeg satte opp et grunnleggende Next-prosjekt med kommandoen yarn create next-app --typescript for å få TypeScript-støtte. Deretter la jeg til Tailwind med kommandoen yarn add tailwindcss postcss autoprefixer og støtte for Sanity med kommandoen yarn add next-sanity. Sanity har en fin guide her til hvordan du syr alt sammen, med de nødvendige konfigurasjonsfilene etc.

Mappestrukturen for nettsidene ser slik ut (jeg har kun tatt med det viktigste, for oversiktens skyld):

1.
2├── components
3│   ├── UI
4│   └── meta.tsx
5├── hooks
6├── lib
7│   ├── api.ts
8│   └── sanity.js
9├── pages
10│   ├── 404.js
11│   ├── _app.tsx
12│   ├── _document.tsx
13│   ├── _error.js
14│   ├── api
15│   │   └── searchContent.ts
16│   ├── blog
17│   │   ├── [slug].tsx
18│   │   └── keyword
19│   │       └── [keyword].tsx
20│   ├── blogposts
21│   │   └── [page].tsx
22│   ├── index.tsx
23│   └── projects.tsx
24├── public
25│   ├── LEKANGER-logo.svg
26│   ├── icons
27│   ├── images
28│   └── kurt-lekanger-tekst-og-kode-og-image.jpg
29├── styles
30│   ├── globals.css
31│   └── markdown.module.css
32└── types
33    └── interfaces.ts

Det er en god idé å tenke gjennom mappestrukturen før prosjektet blir for stort. Jeg pleier å legge alle React-komponentene i mappen components, og denne gangen lagde jeg også en undermappe, UI, for alle komponenter som har med brukergrensesnittet å gjøre. Alle de ulike elementene på siden, som navbar, footer, hero-seksjon, gridvisning av siste bloggposter, osv. ligger i UI-mappen.

Next har en filsystembasert løsning for ruting, som betyr at det å legge til en ny side for nettsiden er så enkelt som å opprette en ny fil i pages-mappen, som du så eksporterer en React-komponent fra. Filen med navn index.tsx på rotnivå i pages-mappen er forsiden til nettsiden. Jeg trengte også å lage dynamiske ruter til blant annet alle bloggposter/artikler. I mitt tilfelle har jeg laget en undermappe blog som inneholder filen [slug].tsx. Når nettsidene bygges vil Next hente inn én og én bloggpost fra Sanity og erstatte [slug] med slug-en for hver bloggpost, slik at hver bloggpost får en URL som kan se for eksempel slik ut: https://www.lekanger.no/blog/slik-bygget-jeg-denne-nettsiden.

I pages-mappen er det også et par spesielle filer, _app.tsx og _document.tsx. _app.tsx brukes til å initialisere hver enkelt side, og kan brukes hvis du vil legge til noe for alle sider i prosjektet ditt – i mitt tilfelle la jeg til en Navbar-komponent for å ha menylinjen synlig på alle sider. Dermed slipper jeg å legge til menylinjen flere steder i kodebasen:

1// ./pages/_app.tsx
2
3import { ThemeProvider } from 'next-themes';
4import type { AppProps } from 'next/app';
5import 'tailwindcss/tailwind.css';
6import Navbar from '../components/UI/navbar';
7
8import '../styles/globals.css';
9
10function MyApp({ Component, pageProps }: AppProps) {
11  return (
12    <ThemeProvider attribute='class' defaultTheme='system'>
13      <Navbar />
14      <Component {...pageProps} />
15    </ThemeProvider>
16  );
17}
18export default MyApp;
19

Component er den nettsiden som vises for øyeblikket. Legg også merke til at jeg wrapper alt sammen i ThemeProvider som kommer fra pakken next-themes. Dette gir full støtte for dark mode, inkludert mulighet til å lese av om brukeren foretrekker mørk eller lys modus. Med dette på plass kan jeg bruke hooken useTheme til å lage en knapp i menylinjen for å svitsje mellom mørkt eller lyst tema.

Den andre spesialfilen under pages er _document.tsx. Denne filen er for oppdatering av <html> og <body>-taggene på en nettside. I mitt tilfelle er det her jeg laster inn Google-fontene jeg bruker på nettsiden, samt scriptet Plauisible bruker for å lage besøksstatistikk.

Før jeg kommer inn på hvordan jeg henter innhold fra Sanity til hver enkelt side, skal jeg helt kort si litt om Sanity Studio, løsningen jeg bruker for å publisere og redigere innhold.

Sanity Studio

Sanity lar deg definere skjemaer for innholdet ditt i form av kode, og så snart innholdsmodellen din er satt opp kan du kjøre Sanity Studio enten lokalt eller hoste det andre steder (det er også mulig å gjøre det hos Sanity). Studio henter innholdet fra Sanitys servere, fra det de kaller "Content Lake", og innholdet lagres aldri lokalt. Dermed er det fort gjort å sette opp Studio på en ny PC, og du kan være trygg på at du alltid redigerer siste versjon av innholdet. Det er faktisk også støtte for at flere brukere kan redigere det samme innholdet, og det er versjonskontroll slik at du kan gå tilbake til tidligere versjoner.

Som nevnt har jeg brukt Turborepo til å lage et monorepo som innholder både nettsiden og Sanity Studio. Mappestrukturen til monorepoet ser (forenklet) slik ut:

1.
2├── apps
3│   ├── studio
4│   │   └── schemas
5│   └── web
6└── packages
7    ├── config
8    ├── tsconfig
9    └── ui

Under packages ligger komponenter og konfigurasjoner som skal være delt mellom for eksempel ulike websider. Under apps ligger Sanity Studio i mappen studio og Next-nettsidene i mappen web. Tanken her er at jeg skal kunne opprette andre websider senere under apps-mappen og ha noen felles komponenter, slik at jeg sparer utviklingstid.

Jeg skal ikke gå inn på alle detaljer rundt hvordan du setter opp Sanity Studio, men kort fortalt må du definere ett eller flere skjemaer som beskriver ulike dokumenttyper. En dokumenttype kan være for eksempel en samling artikler, forfattere, bilder, osv. Sanity har en grundig innføring i hvordan skjemaene er bygget opp. I skjemaene kan du lage koblinger mellom ulike dokumenttyper, for eksempel slik jeg har gjort for Forfatter-feltet som er et "array" som skal inneholde referanser til dokumenter av type "author". På den måten kan en artikkel ved behov ha mer enn én forfatter:

1import {format, parseISO} from 'date-fns'
2import {MdArticle} from 'react-icons/md'
3
4export default {
5  name: 'project',
6  title: 'Artikler',
7  type: 'document',
8  icon: MdArticle,
9  fields: [
10    {
11      name: 'title',
12      title: 'Tittel',
13      type: 'string',
14    },
15    {
16      name: 'slug',
17      title: 'Slug',
18      type: 'slug',
19      description: 'Alle artikler må ha en "slug" for å lage en unik URL.',
20      options: {
21        source: 'title',
22        maxLength: 96,
23        slugify: (input) => input.toLowerCase().replace(/\s+/g, '-').slice(0, 200),
24      },
25      validation: (Rule) => Rule.required().error('En slug kan se slik ut: "dette-er-en-artikkel"'),
26    },
27    {
28      name: 'publishedAt',
29      title: 'Publiseringstidspunkt',
30      description: 'Du kan bruke dette feltet til å forhåndspublisere artikler.',
31      type: 'datetime',
32    },
33    {
34      name: 'pinned',
35      title: 'Pinned',
36      type: 'boolean',
37    },
38    {
39      name: 'excerpt',
40      title: 'Kort oppsummering',
41      type: 'simplePortableText',
42    },
43    {
44      name: 'author',
45      title: 'Forfatter',
46      type: 'array',
47      of: [{type: 'reference', to: {type: 'author'}}],
48    },
49
50    {
51      name: 'mainImage',
52      title: 'Hovedbilde',
53      type: 'figure',
54    },
55    {
56      name: 'categories',
57      title: 'Kategorier',
58      type: 'array',
59      of: [{type: 'reference', to: {type: 'category'}}],
60    },
61    {
62      name: 'body',
63      title: 'Brødtekst',
64      type: 'projectPortableText',
65    },
66    {
67      name: 'relatedProjects',
68      title: 'Relaterte artikler',
69      type: 'array',
70      of: [{type: 'reference', to: {type: 'project'}}],
71    },
72    {
73      name: 'keywords',
74      type: 'array',
75      title: 'Emneknagger',
76      description: 'Emneknagger som beskriver artikkelen',
77      of: [{type: 'string'}],
78      options: {
79        layout: 'tags',
80      },
81    },
82  ],
83  preview: {
84    select: {
85      title: 'title',
86      publishedAt: 'publishedAt',
87      slug: 'slug',
88      media: 'mainImage',
89    },
90    prepare({title = 'Ingen tittel', publishedAt, slug = {}, media}) {
91      const dateSegment = publishedAt ? format(parseISO(publishedAt), 'yyyy/MM') : null
92
93      const path = `/${dateSegment}/${slug.current}/`
94      return {
95        title,
96        media,
97        subtitle: publishedAt ? path : 'Manglende publiseringsdato',
98      }
99    },
100  },
101}
102

Legg også merke til brødtekst-feltet som er av typen projectPortableText. Denne dokumenttypen er definert i en egen fil som definerer en array med såkalte blokker. Sanity kaller dette for Portable Text, og dette er en JSON-basert åpen kildekode-spesifikasjon for rikt tekstinnhold. For å rendre tekstinnholdet i Next bruker jeg biblioteket react-portabletext. Fordelen er at Sanity ikke kontrollerer hvordan teksten til slutt skal rendes i den ferdige web-applikasjonen. Når jeg henter innholdet fra Sanity og rendrer det med React Portable Text kan jeg selv gjennom kode definere hvordan ulike typer blokker (for eksempel tekst, et bilde, en kodesnutt) skal rendres. Og jeg kan lage helt nye typer blokker i Sanity, for eksempel om jeg trenger å embedde videoer eller kart, og så kan jeg selv definere hvordan jeg ønsker å rendre disse.

Når du har laget skjemaer for de ulike dokumenttypene, og koblingene mellom dem, og konfigurert det hele i henhold til Sanitys beskrivelse, vil dette gjenspeiles i innholdseditoren Sanity Studio. Slik ser det ut hos meg, der jeg har definert skjematyper for artikler, innhold til undersider (for eksempel "Om meg"), innhold til forsidemoduler (for eksempel Hero-seksjonen), osv.:

Artikkeleditoren Sanity Studio.
Slik ser artikkeleditoren i Sanity ut.

Jeg nevner også at jeg har satt opp "build hooks" mellom Vercel og Sanity, slik at nettsidene bygges på nytt så snart jeg publiserer nytt innhold.

Hente data fra Sanity

Jeg brukte som nevnt Sanity også på de gamle nettsidene mine, og da brukte jeg GraphQL for å gjøre spørringer og hente ut de aktuelle dataene. For de nye Next-nettsidene valgte jeg imidlertid å bruke Sanitys eget spørrespråk, Groq. Det tok litt tid å venne seg til syntaksen, men Groq er et ganske kraftig spørrespråk som lar deg filtrere ut kun de dataene du trenger fra Sanitys Content Lake. Jeg valgte å gjøre som i ett av Sanitys egne eksempelprosjekter, og la alle spørringene som funksjoner som jeg eksporterte fra filen ./lib/api.ts. For eksempel ser funksjonen for å hente ut teksten som brukes på Om meg-siden slik ut:

1// ./lib/api.ts
2import  { client, previewClient } from './sanity';
3
4export async function getAboutMePageText() {
5  const data = await client.fetch(
6    groq`*[_type == "webContent" && webContentType == 'about-me']`
7  );
8
9  return data[0];
10}
11
12// ...

Queryen starter med en * som betyr "gi meg alle dokumentene i datasettet". Innenfor hakeparentesene [] defineres et filter der vi ber om alle dokumenter av typen webContent der webContentType er about-me. Jeg har definert denne dokumenttypen i et skjema i Sanity, og gitt denne tittelen "Undersider på nettsiden" (det er denne tittelen som dukker opp som et menyvalg inne i innholdseditoren Sanity Studio). Når jeg legger inn en ny underside i Sanity Studio kan jeg velge mellom ulike "webContentTypes", for eksempel Om meg-side, Kontakt meg-side, Personvern-side, osv. Dette er av de enklere spørringene jeg gjør, når jeg for eksempel skal hente inn artikler til samlesiden som viser alle bloggposter, med paginering, trengs det litt mer avanserte spørringer.

I Next kan du forhåndsrendre en side når den bygges (dvs. lage en statisk versjon av siden). Det gjør du ved å eksportere en funksjon som heter getStaticProps fra den aktuelle siden, og i denne funksjonen hente de dataene du trenger for å bygge en statisk versjon av siden. Eksempel: Jeg har en side som heter ./pages/about-me.tsx, som blir "Om meg"-siden som dukker opp når brukeren besøker lekanger.no/about-me. Den ser slik ut (legg merke til at jeg kaller funksjonen getAboutMeText nederst):

1// ./pages/about-me.tsx
2
3import { imageBuilder } from 'lib/sanity';
4import type { NextPage, GetStaticProps } from 'next';
5import ErrorPage from 'next/error';
6import { useRouter } from 'next/router';
7import Meta from '../components/meta';
8import Container from '../components/UI/container';
9import Layout from '../components/UI/layout';
10import PostArticle from '../components/UI/post-article';
11import PostTitle from '../components/UI/post-title';
12import { getAboutMePageText } from '../lib/api';
13import { AboutMePageProps } from '../types/interfaces';
14
15const AboutMe: NextPage<AboutMePageProps> = ({ aboutMeText }) => {
16  const router = useRouter();
17
18  if (!router.isFallback && !aboutMeText?.title) {
19    return <ErrorPage statusCode={404} />;
20  }
21
22  const ogImageUrl =
23    imageBuilder(aboutMeText.mainImage?.asset).width(1200).height(630).url() ||
24    '#';
25
26  return (
27    <>
28      <Meta
29        titleTag={`${aboutMeText?.title}`}
30        ogImage={ogImageUrl}
31        ogUrl='https://www.lekanger.no/about-me'
32        description='Om meg og hva jeg driver med'
33      />
34      <Layout preview={false}>
35        <Container>
36          <article>
37            {router.isFallback ? (
38              <PostTitle>Laster innhold...</PostTitle>
39            ) : (
40              <PostArticle
41                title={aboutMeText?.title}
42                coverImage={aboutMeText.mainImage}
43                content={aboutMeText.body}
44              />
45            )}
46          </article>
47        </Container>
48      </Layout>
49    </>
50  );
51};
52
53export default AboutMe;
54
55export const getStaticProps: GetStaticProps = async ({ preview = false }) => {
56  const aboutMeText = await getAboutMePageText();
57
58  return {
59    props: { aboutMeText },
60    revalidate: 1,
61  };
62};
63

I getStaticProps returnerer jeg de propsene jeg vil skal være tilgjengelige som props for AboutMe-komponenten.

Nettsiden har selvfølgelig også en del sider som er basert på dynamiske ruter, som for eksempel at hver gang jeg publiserer en ny artikkel så må det bygges en ny statisk side med innholdet til denne artikkelen. I slike tilfeller må jeg i tillegg til getStaticProps eksportere funksjonen getStaticPaths. Denne kjøres kun når siden bygges og returnerer et objekt med blant annet propertyen paths som igjen inneholder et array med det som trengs for å lage en unik URL for hver side. I mitt tilfelle har jeg i getStaticPaths en spørring som henter slugen til alle artikler, og så mottar getStaticProps disse og kan bruke hver slug i en Groq-spørring som henter ut innholdet i artikkelen med denne slugen.

Hvis det virker komplisert, så er det kanskje bedre beskrevet i Next-dokumentasjonen her.

Slik ser getStaticPaths og getStaticProps ut hos meg, i filen ./pages/blog/[slug].tsx:

1// ./pages/blog/[slug].tsx
2
3const Post: NextPage<PostProps> = ({
4  post,
5  //  morePosts,
6  relatedPosts,
7  preview,
8}) => {
9  
10// .
11// ... resten av koden
12// .
13}
14
15export default Post;
16
17
18export async function getStaticPaths() {
19  const allPosts: { slug: string }[] = await getAllPostsWithSlug();
20
21  return {
22    paths:
23      allPosts?.map((post) => ({
24        params: {
25          slug: post.slug,
26        },
27      })) || [],
28    fallback: false,
29  };
30}
31
32export async function getStaticProps({
33  params,
34  preview = false,
35}: {
36  params: { slug: string };
37  preview?: boolean;
38}) {
39  const data = await getPostAndMorePosts({
40    slug: params.slug,
41    preview: false,
42  });
43
44  const relatedPosts = await getRelatedPosts({
45    slug: params.slug,
46    preview: false,
47  });
48
49  return {
50    props: {
51      preview,
52      post: data?.post || null,
53      morePosts: data?.morePosts || null,
54      relatedPosts,
55    },
56    revalidate: 1,
57  };
58}
59

CSS og styling

I enkle prosjekter hender det jeg skriver CSS-en manuelt på den gode, gamle måten, og jeg har også tidligere brukt både Styled Components og CSS modules i React-prosjekter for å "scope" navn på CSS-klasser til hver enkelt komponent. Denne gangen tenkte jeg å gi Tailwind CSS et forsøk. Jeg synes det gikk ganske fort å komme inn i syntaksen, og jeg liker spesielt godt at det er forholdsvis enkelt å definere temaer med de fargene, fontene, osv. du ønsker. Dette gjøres i konfigurasjonsfilen tailwind.config.js, der du kan utvide standardtemaet.

Jeg ville finne en fin fargepalett for både mørk og lys skjermmodus, og brukte tjenestene Mycolor Space og Coolors til å finne farger jeg likte. For å lage den mørke versjonen av fargepaletten brukte jeg Figma til å tegne opp et "fargekart" med de lyse fargene, for deretter å speilvende dette fargekartet på en mørk bakgrunn. Så justerte jeg de mørke fargene slik at de ble lyse nok til å gi god nok kontrast på den mørke bakgrunnen:

Ved å vise den lyse fargepaletten på mørk bakgrunn, var det enkelt å justere hver farge til kontrasten var god nok.
Ved å vise den lyse fargepaletten på mørk bakgrunn, var det enkelt å justere hver farge til kontrasten var god nok.

Under theme -> extend -> colors i tailwind.config.js lagde jeg så definisjoner for alle fargene (og prefikset dem med "brand", for å skille dem fra de vanlige tailwind-fargene (f.eks. red. For eksempel slik:

1// ...
2
3colors: {
4        brand: {
5          main1: '#91a4a2',
6          'main1-70': 'rgba(145, 164, 162, 0.7)',
7          'main1-50': 'rgba(145, 164, 162, 0.5)',
8          'main1-30': 'rgba(145, 164, 162, 0.3)',
9          'main1-10': 'rgba(145, 164, 162, 0.1)',
10          
11// ...
12
13       'brand-dark': {
14          main1: '#A1B0AF',
15          'main1-70': 'rgba(161, 176, 175, 0.7)',
16          'main1-50': 'rgba(161, 176, 175, 0.5)',
17          'main1-30': 'rgba(161, 176, 175, 0.3)',
18          'main1-10': 'rgba(161, 176, 175, 0.1)',
19          
20// ...

Deretter kan jeg style et element med Tailwind CSS på denne måten for å gi elementet en bakgrunnsfarge med 10%-versjonen av fargen brand-main1 i henholdsvis lys og mørk modus:

1<div className='bg-brand-main1-10 dark:bg-brand-dark-main1-10'>

Støtte for mørk modus er innebygget i Tailwind CSS, men for å gjøre det enklere å lage en knapp for å svitsje modus, og for å automatisk lese hvilken modus brukeren har valgt i sine systeminnstillinger, brukte jeg next-themes. Denne oppdaterer HTML-elementet på siden med det som er nødvendig for å indikere overfor Tailwind om siden skal vises i mørk eller lys modus. Hvis brukeren manuelt velger mørk eller lys modus, lagres preferansen i localStorage, hvis ikke følges det som er oppgitt i systeminnstillingene (prefers-color-scheme).

Et problem med statisk bygde nettsider (som Next) og mørk modus, er at nettsiden i et kort øyeblikk kan vises i feil modus siden serveren som leverer de statiske nettsidene jo ikke kan vite hvilken modus brukeren foretrekker. Next-themes forhindrer dette ved å injisere et script i next/head som sørger for å oppdatere html-elementet med de nødvendige attributtene før resten av siden lastes.

SEO, tilgjengelighet og annet finpuss

Gode nettsider skal ikke bare være noenlunde fine å se på, de bør også være raske, godt søkemotoroptimaliserte, og ikke minst bør kravene til tilgjengelighet ("accessibility") være godt ivaretatt. Når nettsidene bygges med Next kommer hastigheten litt av seg selv, i hvert fall når jeg deployer nettsidene til Vercels servere. Vercel har en CDN-løsning de kaller "The Vercel Edge Network" som i praksis betyr at nettsidene caches og serveres fra datasentre nær der brukerne befinner seg. Dette er ikke noe man trenger å forholde seg til, det å deploye et Next-nettsted til Vercel er så enkelt som å peke til Github-repoet og sette opp nødvendige environment-variabler, så bygges nettsidene automatisk hver gang du pusher endringer i koden til Github.

Det er selvfølgelig en del ting jeg selv må passe på for at nettsidene skal bli raske (målet er jo 100/100 i Lighthouse). Det ene er å sørge for at bildene leveres i riktige størrelser og i moderne bildeformater som webp. Mine bilder leveres fra Sanitys CDN, og Sanity har fine verktøy for å hente ut bildene i riktig størrelse og gjøre dem tilgjengelige under Next.js' byggeprosess. Next har også en egen bildekomponent som automatisk optimaliserer og serverer bilder i riktige formater og størrelser avhengig av skjermstørrelse, hva brukerens nettleser støtter, osv.

Når det gjelder søkemotoroptimalisering (SEO), er det viktig ikke bare å sørge for at innholdet er godt søkemotoroptimalisert, men også at det tekniske er på plass. Det er en del viktige metatagger Google leser og forstår, som plasseres i <head>-seksjonen i HTML-koden. Next.js har en egen Head-komponent som kan importeres fra next/head – alt som plasseres mellom <Head> og </Head> havner i head-seksjonen i HTML-koden.

Jeg har laget en komponent jeg har kalt Meta som jeg importerer og bruker i toppen av alle sider. Som standard legger den til det mest nødvendige, som en god <title>-tag med beskrivelse av siden, samt metatager for blant annet description. Denne Meta-komponenten kan som props ta imot en ny title-tag, beskrivelse og bilde, samt Open Graph-metatagger som sørger for at det er mulig å dele artikler og innhold i sosiale medier som Facebook, og få med riktige bilder, URL-er, og så videre. Når Next bygger for eksempel artikkelsidene, vil sidene få metatagger skreddersydd for hver enkelt side for blant annet og:image, og:url, og:description, description og <title>. Et lite tips her er å bruke Facebook Sharing Debugger til å sjekke de enkelte sidene av nettsiden for å sikre at innholdet ser bra ut når det deles. Et klassisk problem hvis dette ikke er gjort riktig, er at kun et generisk forsidebilde vises i stedet for artikkelbildet når noen prøver å dele en artikkel i sosiale medier. LinkedIn har også en Post Inspector som lar deg sjekke om alle metadata for en gitt side er korrekte.

Når det gjelder tilgjengelighet har jeg brukt utviklerverktøyet i Chrome til å sjekke at i hvert fall det viktigste er ivaretatt når det gjelder slike ting som for eksempel kontrast mellom tekst og bakgrunn, tab-rekkefølge, osv. Det er nok fortsatt mye som kan gjøres for at alt skal være 100 % i tråd med WCAG 2, og at alt skal fungere som det skal med skjermlesere, leselister og andre verktøy – men jeg har i hvert fall hatt et bevisst forhold til tilgjengelighet, blant annet ved å bruke semantiske HTML-tagger i stedet for å strø om meg med <div>-er overalt, samt å legge til aria-labels der det har vært nødvendig.

...og noen subtile animasjoner

Jeg synes mange nettsider overdriver når det gjelder animasjoner og effekter, men samtidig synes jeg det kan bli litt kjedelig uten animasjoner i det hele tatt. Jeg ville derfor ha noen litt subtile animasjoner når brukeren scrollet på forsiden, slik at nytt innhold glir mykt inn fra bunnen av siden. Dette kan gjøres manuelt ved å bruke nettleserens Intersection Observer API, og for eksempel legge til og fjerne klasser når nytt innhold kom inn på skjermen – og vanlige CSS-animasjoner til å animere disse klassene. Jeg fant imidlertid ut at jeg heller ville bruke en ferdig løsning, og da sto valget mellom Framer Motion og GSAP. Ettersom jeg hadde erfaring med Framer Motion fra før, og ville lære meg noe nytt, gikk jeg for GSAP denne gangen. Ikke minst fordi jeg nylig hadde sett noen utrolig imponerende eksempler på hva som er mulig å få til med dette animasjonsbiblioteket. Jeg hadde ikke ambisjoner om å lage noe spesielt avansert denne gangen, så det var ikke snakk om mange kodelinjene for å få til de animasjonene jeg ønsket på forsiden.

Etter å ha installert GSAP med kommandoen yarn add gsap (eller npm install), trengte jeg også å importere GSAPs ScrollTrigger for å gjøre det mulig å starte animasjonene i det nytt innhold dukker opp på skjermen. Jeg brukte deretter Reacts useRef-hook og lagde et array som inneholdt en ref for hvert av de elementene jeg ønsket å animere. Så, inne i useEffect, la jeg til koden som looper over alle disse ref-ene og animerer de ulike elementene. I mitt tilfelle definerte jeg at elementene skulle starte med y-posisjonen forskjøvet 100 piksler, og animere jevnt til 0 basert på brukerens scrollposisjon. Du kan lese mer om hvordan Scrolltrigger og GSAP fungerer her. Slik ser index.tsx-filen (<Home>-komponenten) min ut. Legg merke til addToRefs, jeg måtte legge disse på en <div>-tag som wrappet rundt den React-komponenten jeg ville animere for at ting skulle fungere som det skulle:

1import gsap from 'gsap';
2import { ScrollTrigger } from 'gsap/dist/ScrollTrigger';
3import type { NextPage, GetStaticProps } from 'next';
4import { useEffect, useRef } from 'react';
5import Meta from '../components/meta';
6import AboutMe from '../components/UI/about-me';
7import ContactMe from '../components/UI/contact-me';
8import CustomerStory from '../components/UI/customer-story';
9import FeaturedBlogPosts from '../components/UI/featured-blog-posts';
10import Hero from '../components/UI/hero';
11import Layout from '../components/UI/layout';
12import { HomePageProps } from '../types/interfaces';
13
14import {
15  getAboutMeText,
16  getAllPostsForHome,
17  getCustomerStoryText,
18  getHeroText,
19} from '../lib/api';
20
21const Home: NextPage<HomePageProps> = ({
22  allPosts,
23  heroText,
24  aboutMeText,
25  customerStoryText,
26}: HomePageProps) => {
27  gsap.registerPlugin(ScrollTrigger);
28  const revealRefs = useRef([] as HTMLDivElement[]);
29  revealRefs.current = [];
30
31  const addToRefs = (el: HTMLDivElement) => {
32    revealRefs.current.push(el);
33  };
34
35  useEffect(() => {
36    revealRefs.current.forEach((el, i) => {
37      gsap.from(el, {
38        y: 100,
39        scrollTrigger: {
40          id: `section-${i + 1}`,
41          trigger: el,
42          start: 'top bottom',
43          end: 'bottom bottom',
44          scrub: 1,
45        },
46      });
47    });
48  }, []);
49
50  return (
51    <>
52      <Meta />
53      <Layout preview={false}>
54        <Hero content={heroText} />
55        <div className='scroll-reveal' ref={addToRefs}>
56          <FeaturedBlogPosts content={allPosts} />
57        </div>
58        <div className='scroll-reveal' ref={addToRefs}>
59          <AboutMe content={aboutMeText} />
60        </div>
61        <div className='scroll-reveal' ref={addToRefs}>
62          <CustomerStory content={customerStoryText} />
63        </div>
64        <div className='scroll-reveal' ref={addToRefs}>
65          <ContactMe />
66        </div>
67
68        <br />
69      </Layout>
70    </>
71  );
72};
73
74export default Home;
75
76export const getStaticProps: GetStaticProps = async ({ preview = false }) => {
77  const allPosts = await getAllPostsForHome({
78    preview,
79    numberOfPosts: 4,
80    offset: 0,
81  });
82
83  const heroText = await getHeroText();
84
85  const aboutMeText = await getAboutMeText();
86  const customerStoryText = await getCustomerStoryText();
87
88  return {
89    props: {
90      allPosts,
91      heroText,
92      aboutMeText,
93      customerStoryText,
94    },
95    revalidate: 1,
96  };
97};
98

Det var det!

Det gjenstår helt sikkert litt finpuss her og der, og jeg har tenkt å refaktorere koden litt noen steder – men i det store og hele er jeg ganske fornøyd med resultatet.

Jeg har få hemmeligheter, så hvis du er nysgjerrig så finner du all kildekoden på min Github her. Sanity Studio er oppgradert til en developer preview av Studio v3, og er litt "buggete". Det mangler foreløpig også en forhåndsvisningsfunksjon. Dette er noe jeg skal prioritere å få på plass, slik at jeg med et museklikk i Sanity Studio kan forhåndsvise hvordan en ikke-publisert artikkel vil bli seende ut på de faktiske nettsidene.

Publisert: 27. juli 2022 (oppdatert: 03. august 2022)
#utvikling#next.js#sanity.io#tailwind css#web-utvikling#react.js