# mk-s25-week-0-frontend-notes
Below is a **minimal, one-file starter** that shows the whole flow:
* **Airtable → server-side fetch → JSON mapping → TimelineJS JSON → render in the browser**
* **Everything configurable from a single `FIELD_MAP` and a small `options` object**
You can drop the file into a standard **`pages/`**-based Next 13/14 project that already has Tailwind set up.
(If you’re on the new **App Router**, move the code into `app/timeline/page.tsx` and keep the same logic.)
---
### 1 | Elastic Airtable schema
```ts
/**
* Update the keys on the left if your Airtable columns change.
* Keep the keys on the right—the code below expects those.
*/
export const FIELD_MAP = {
// --- dates ---
startDate: 'Start Date', // ISO date or Airtable “Date” type
endDate: 'End Date', // optional
displayDate:'Display Date', // optional string (“Spring 1999” etc.)
// --- narrative ---
headline: 'Headline',
text: 'Body',
// --- media ---
mediaUrl: 'Media URL',
mediaCap: 'Media Caption',
mediaCred: 'Media Credit',
// --- misc ---
group: 'Group', // optional row label
bgColor: 'BG Color', // #hex or CSS name
bgImage: 'BG Image' // URL
} as const;
```
**Why this shape?**
* TimelineJS cares only about the final JSON keys (`start_date`, `media`, `text`, …) — we can call Airtable fields anything we like and translate with `FIELD_MAP`.
* Adding / renaming a column is now a one-line change instead of a refactor.
---
### 2 | `pages/timeline.tsx`
```tsx
import Head from 'next/head'
import { GetServerSideProps } from 'next'
import { useEffect, useRef } from 'react'
import { FIELD_MAP } from '@/lib/airtableFieldMap'
type AirRec = { id: string; fields: Record<string, any> }
type TLDate = { year:string; month?:string; day?:string }
type TLSlide = {
start_date: TLDate
end_date?: TLDate
media?: { url:string; caption?:string; credit?:string }
text?: { headline?:string; text?:string }
group?: string
display_date?: string
background?: { color?:string; url?:string }
unique_id: string
}
interface PageProps {
timeline: { title?: TLSlide; events: TLSlide[] }
}
export const getServerSideProps: GetServerSideProps<PageProps> = async () => {
/* --- Airtable fetch ------------------------------------------- */
const { AIRTABLE_BASE, AIRTABLE_TABLE, AIRTABLE_TOKEN } = process.env
const url = `https://api.airtable.com/v0/${AIRTABLE_BASE}/${encodeURIComponent(
AIRTABLE_TABLE!
)}`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${AIRTABLE_TOKEN}` },
})
const { records }: { records: AirRec[] } = await res.json()
/* --- helper to turn an Airtable record → Timeline slide -------- */
const toDate = (d: string): TLDate => {
const dt = new Date(d)
return {
year: dt.getUTCFullYear().toString(),
month: (dt.getUTCMonth() + 1).toString(),
day: dt.getUTCDate().toString(),
}
}
const recToSlide = (r: AirRec): TLSlide => {
const f = r.fields
return {
unique_id: r.id,
start_date: toDate(f[FIELD_MAP.startDate]),
end_date: f[FIELD_MAP.endDate] ? toDate(f[FIELD_MAP.endDate]) : undefined,
display_date: f[FIELD_MAP.displayDate],
group: f[FIELD_MAP.group],
text:
f[FIELD_MAP.headline] || f[FIELD_MAP.text]
? {
headline: f[FIELD_MAP.headline],
text: f[FIELD_MAP.text],
}
: undefined,
media: f[FIELD_MAP.mediaUrl]
? {
url: f[FIELD_MAP.mediaUrl],
caption: f[FIELD_MAP.mediaCap],
credit: f[FIELD_MAP.mediaCred],
}
: undefined,
background:
f[FIELD_MAP.bgColor] || f[FIELD_MAP.bgImage]
? { color: f[FIELD_MAP.bgColor], url: f[FIELD_MAP.bgImage] }
: undefined,
}
}
const events = records.map(recToSlide)
/* --- optional title slide: treat the first record tagged “Title” */
const titleRec = records.find((r) => r.fields['Is Title'] === true)
const titleSlide = titleRec ? recToSlide(titleRec) : undefined
return {
props: { timeline: { title: titleSlide, events } },
}
}
/* ---------------- main component (client-side Timeline) ---------- */
export default function TimelinePage({ timeline }: PageProps) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
// Load TimelineJS once the component is on the client
const script = document.createElement('script')
script.src =
'https://cdn.knightlab.com/libs/timeline3/latest/js/timeline.js'
script.onload = () => {
// @ts-ignore – TL is attached to window by the script
new window.TL.Timeline(ref.current!, timeline, {
initial_zoom: 3,
timenav_height: 250,
})
}
document.body.appendChild(script)
}, [timeline])
return (
<>
<Head>
<title>My Airtable Timeline</title>
<link
rel="stylesheet"
href="https://cdn.knightlab.com/libs/timeline3/latest/css/timeline.css"
/>
</Head>
{/* Tailwind utility classes keep it centred & responsive */}
<main className="flex min-h-screen items-center justify-center bg-gray-50">
<div
id="timeline-embed"
ref={ref}
className="w-full max-w-6xl"
style={{ height: '600px' }}
/>
</main>
</>
)
}
```
**How it works**
1. **Server-side** (`getServerSideProps`)
* Uses **token-based auth** (Airtable personal access token) ([Airtable][1])
* Pulls every record in the chosen table (adjust with `view=` or `filterByFormula` if needed).
* Maps each record → TimelineJS “slide” using the **`FIELD_MAP`** above.
2. **Client-side** (`useEffect`)
* Dynamically injects the TimelineJS script from the Knight Lab CDN.
* Instantiates `new TL.Timeline(container, data, options)`; options are tweakable exactly as in the docs ([Timeline JS][2]).
3. **Styling**
* Tailwind utilities give you a centred timeline at **max width 6 XL** and **600 px height**.
* Override with inline styles or Timeline’s own `width/height` options.
---
### 3 | What you might want to tweak
| Area | Knob | Why / When |
| ------------------ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| **Airtable** | `AIRTABLE_BASE`, `AIRTABLE_TABLE`, `AIRTABLE_TOKEN` (env vars) | Point to a different base/table or rotate credentials. |
| | `view=` param in the fetch URL | Pull only a filtered view so you don’t expose unpublished events. |
| **Mapping** | `FIELD_MAP` keys | Rename Airtable columns without touching code. |
| **Timeline** | Options object in `new TL.Timeline(…)` | Height, `initial_zoom`, fonts, hash bookmarking, etc. (full list in docs) ([Timeline JS][2]) |
| **Title slide** | Logic that grabs `Is Title` flag | Prefer a dedicated “Title” table or pick the first record—up to you. |
| **Styling** | Tailwind classes on the wrapper | Put the timeline in a grid, dark-mode background, etc. |
| **Build strategy** | Replace `getServerSideProps` with `getStaticProps` | If your timeline changes rarely, pre-render and revalidate on a schedule. |
---
### Quick next steps
1. **Create a personal access token** in Airtable with *data.records\:read* scope for your base.
Put it into `.env.local` as `AIRTABLE_TOKEN=`.
2. Copy the code file into `pages/timeline.tsx`.
3. Adjust `FIELD_MAP` to match your column names.
4. Run `npm run dev`, visit **`/timeline`**, and you should see the interactive timeline.
That’s all you need to get Airtable-driven timelines humming inside your Next.js project. Happy storytelling!
[1]: https://www.airtable.com/developers/web/api/authentication?utm_source=chatgpt.com "Authentication - Airtable Web API"
[2]: https://timeline.knightlab.com/docs/options.html?utm_source=chatgpt.com "TimelineJS options - Knight Lab"