Overview#
Agency is a complete website for a creative or digital agency, built for the fictional Northbound Studio (London, Lisbon and New York): a big-type home page with a rotating word, marquee and testimonial carousel, services with engagement models and price ranges, a filterable work page, a full case study with a before/after slider, an about page with team portraits, a careers page with filterable roles and an application form, and a detailed project-brief contact form. This is the Next.js 16 (App Router) + React 19 + TypeScript + Tailwind CSS v4 version, version 1.1.4. It exports to static files. See it live at freeframe.dev/templates/agency.
Quick start#
Requirements: Node.js 24 or newer and pnpm (npm works too).
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm build |
Static export to out/ |
pnpm start |
Serve out/ locally (npx serve out) |
pnpm lint |
ESLint |
pnpm typecheck |
Generate route types, then type-check with tsc (TypeScript 7) |
pnpm build:demo |
Build for /demos/nextjs/agency into demo/ (only used for the freeframe.dev demo) |
Pages#
| Route | File | What's on it |
|---|---|---|
/ |
src/app/page.tsx |
Line-by-line headline with a rotating word, featured project, services marquee, client logos, services, selected work, process, count-up numbers and awards, testimonial carousel |
/services |
src/app/services/page.tsx |
Four services with deliverables and timelines, engagement models with price ranges, FAQ |
/work |
src/app/work/page.tsx |
All case studies with an industry filter; /work#fintech opens it filtered |
/case-study |
src/app/case-study/page.tsx |
Kite Mobility: facts, challenge, before/after slider, approach, brand system, app screens, results, quote, credits, next project |
/about |
src/app/about/page.tsx |
Studio illustration, story timeline, values, team portraits |
/careers |
src/app/careers/page.tsx |
Perks, open roles with a team filter and expandable details, application form |
/contact |
src/app/contact/page.tsx |
Project-brief form, email addresses, studios with live local times |
| 404 | src/app/not-found.tsx |
Not-found page |
Project structure#
agency/
├─ src/
│ ├─ app/
│ │ ├─ layout.tsx fonts, site-wide metadata, header, footer, reveal controller, back to top
│ │ ├─ globals.css brand tokens (@theme) and component classes
│ │ ├─ icon.svg favicon
│ │ ├─ page.tsx home page (also holds the stats, process, awards and logos lists)
│ │ ├─ <route>/page.tsx services, work, case-study, about, careers, contact
│ │ └─ not-found.tsx
│ ├─ components/
│ │ ├─ site-header.tsx, site-footer.tsx
│ │ ├─ rotator.tsx rotating word in the hero
│ │ ├─ testimonial-carousel.tsx
│ │ ├─ work-grid.tsx, case-card.tsx, filter-chips.tsx work page and its filter
│ │ ├─ careers.tsx roles board with filter, application form, role preselection
│ │ ├─ contact-form.tsx project-brief form
│ │ ├─ forms.tsx shared validation hook, error and success components
│ │ ├─ compare-slider.tsx before/after slider
│ │ ├─ count-up.tsx, local-time.tsx, page-hero.tsx, icons.tsx
│ │ ├─ reveal-controller.tsx headline and scroll-reveal animations
│ │ └─ back-to-top.tsx
│ ├─ fonts/ Bricolage Grotesque, Inter, JetBrains Mono (Latin .woff2) and their OFL licences
│ ├─ data/
│ │ ├─ site.ts studio name, emails, navigation, offices, social links
│ │ ├─ cases.ts industries and case studies
│ │ ├─ careers.ts departments, roles and perks
│ │ ├─ services.ts services, engagement models, FAQ
│ │ └─ team.ts team members and testimonials
│ └─ lib/
│ ├─ asset.ts prefixes /public paths with the base path
│ ├─ metadata.ts pageMetadata() helper for titles and Open Graph
│ └─ use-reduced-motion.ts
├─ public/img/ SVG artwork, logo and og-image.png
├─ next.config.ts, eslint.config.mjs, postcss.config.mjs, tsconfig.json
├─ package.json, pnpm-workspace.yaml, pnpm-lock.yaml
└─ README.md, DOCS.md, CHANGELOG.mdCustomising#
Brand and colours#
The brand tokens are the @theme block at the top of src/app/globals.css:
@theme {
--color-paper: #f1efe9; /* page background */
--color-paper-2: #e7e4dc; /* sunken panels */
--color-ink: #0e0e0c; /* text and dark sections */
--color-muted: #5f5c55; /* secondary text */
--color-line: #d4d0c5; /* hairlines on paper */
--color-cobalt: #2b36f5; /* primary accent */
--color-cobalt-600: #1f28c9;
--color-lime: #d4ff3f; /* highlight accent, used on dark */
}They become utilities such as bg-paper, text-ink, text-muted, border-line, text-cobalt and
bg-lime. To rebrand, change --color-cobalt, --color-cobalt-600 and --color-lime. The browser
theme colour is viewport.themeColor in src/app/layout.tsx. Component classes (.btn,
.btn-primary, .btn-outline, .btn-lime, .btn-ghost-dark, .chip, .eyebrow, .display-xl,
.display-lg, .field, .label, .field-error, .check-tile, .link-u, .marquee, .on-dark...)
live in the same file.
Typography#
Bricolage Grotesque (headings), Inter (body) and JetBrains Mono (labels) are self-hosted with next/font/local
in src/app/layout.tsx, from the Latin .woff2 files in src/fonts/: the same files Google Fonts serves to the HTML
version, so text sets exactly like it (SIL Open Font License texts are the *-OFL.txt files beside them). They are
exposed as --font-bricolage, --font-inter and --font-jetbrains; globals.css maps them to --font-display,
--font-sans and --font-mono. To swap a font, add its .woff2 (and licence) to src/fonts/ and point src at
it, or import a family from next/font/google, and keep the same variable name:
import { Space_Grotesk } from "next/font/google";
const bricolage = Space_Grotesk({ variable: "--font-bricolage", subsets: ["latin"] });Fonts are served from your own site, so visitors make no requests to Google.
Content#
Most content lives in typed data files in src/data/:
-
site.ts:site(studio name and email addresses),nav(header links;alsolists other routes that highlight a link, e.g./case-studyhighlights Work),offices(city, IANA time zone, address) andsocials. -
cases.ts:industriesandcases. Add a project:ts{ client: "Lumen Energy", year: 2026, title: "A clearer way to read your energy bill", industry: "fintech", services: "Product design", image: "/img/case-lumen.svg", href: "/work#fintech" },industrymust be one of theIndustryvalues; to add an industry, extend theIndustrytype andindustries. Filter counts update automatically. Only Kite links to a full case study (/case-study); copysrc/app/case-study/page.tsxto make more. -
careers.ts:departments,roles(title, department, location, type, summary,doList,bringList) andperks. -
services.ts:services,engagementModels(name, duration, price, includes,featured) andserviceFaqs. -
team.ts:team(name, role, image) andtestimonials. -
Home page lists (
disciplines,process,stats,awards,logos) and the rotating words (<Rotator words={[...]} />) are at the top ofsrc/app/page.tsx. -
Case study details (
facts,steps,results,credits) are at the top ofsrc/app/case-study/page.tsx.
Images and artwork#
All artwork is SVG in public/img/. Reference files with asset() from src/lib/asset.ts so they keep
working when the site is served from a sub-folder:
<img src={asset("/img/case-lumen.jpg")} alt="Lumen Energy project artwork" width={1600} height={1000} />Keep case covers at 1600×1000 (16:10), team portraits at 400×480 (5:6), and the social image
(public/img/og-image.png) at 1200×630. .jpg and .webp work fine. The favicon is
src/app/icon.svg.
Navigation, header and footer#
Header links come from nav in src/data/site.ts (used by the desktop and mobile menus). The header is
src/components/site-header.tsx; the footer (offices with live times, social links) is
src/components/site-footer.tsx. To add a page, create src/app/<route>/page.tsx with
export const metadata = pageMetadata("Title", "Description") and add it to nav.
Features#
Header and mobile menu#
The header turns solid once the page scrolls. Below 1024px the menu button opens a full-screen menu: page scrolling is locked, focus moves to the first link, Escape closes it and returns focus, and it closes automatically on wide screens.
Headline, reveals and count-up#
RevealController (mounted in the layout) adds is-loaded to <body> so the .line-mask headline
lines slide up, and fades in .reveal elements as they scroll into view. When one reveals, everything
above it does too, so jumping to an anchor never leaves hidden content. A tiny script in the layout adds
js to <html>, so content is only hidden while JavaScript is running. CountUp animates the home page
numbers (<CountUp to={140} suffix="+" />). With reduced motion everything shows immediately.
Rotating word#
Rotator (src/components/rotator.tsx) cycles through its words every 2.6 seconds. With reduced
motion it shows the first word only.
Marquee#
The services marquee scrolls with the --animate-marquee animation defined in @theme (38 seconds per
loop). Its duplicated track is aria-hidden; it stops with reduced motion.
Testimonial carousel#
TestimonialCarousel shows the testimonials from src/data/team.ts. It advances every 7 seconds
(INTERVAL in the component), pauses on hover, on focus and with the pause button, supports the Left and
Right arrow keys, announces changes in a polite live region, and makes hidden slides inert. With reduced
motion it starts paused.
Work and careers filters#
FilterChips (src/components/filter-chips.tsx) keeps the chosen filter in the URL hash, so /work#retail
and /careers#design can be shared, and reacts if the hash changes. Each chip shows how many items match.
Before / after slider#
CompareSlider on the case study page is a native range input that reveals the "before" image; it works
with dragging, arrow keys and screen readers.
Careers roles and application#
RolesBoard lists roles as expandable <details> elements with a team filter. "Apply for this role"
sets the role through CareersProvider and scrolls to ApplicationForm, which preselects it.
Local times#
LocalTime shows the current time in an IANA time zone (offices[].tz), updated while the page is
open.
Back to top button#
BackToTop (mounted in src/app/layout.tsx) appears after scrolling about one screen, returns to the top
(instantly with reduced motion), moves keyboard focus to #main and is hidden when printing. Remove
<BackToTop /> from the layout to drop it.
Forms#
Both forms use the useValidatedForm hook from src/components/forms.tsx: fields are checked with the
browser's Constraint Validation API on submit and re-checked as they change, errors appear under each
field (FieldError, linked with aria-describedby through describe(name)), focus moves to the first
problem, and checkbox groups need at least one ticked box. A valid form calls onValid, which shows a
SuccessPanel.
| Form | Component | Fields and rules |
|---|---|---|
| Project brief | ContactForm (src/components/contact-form.tsx) |
Name, email, company (required); services checkboxes (at least one); budget range (labels in BUDGETS); timeline (required); message (20+ characters); source (optional) |
| Application | ApplicationForm (src/components/careers.tsx) |
Name, email, role (required); portfolio (required, full https:// URL); location (optional); note (30+ characters); consent (required) |
The forms don't send anything yet — onValid only shows the success panel. To deliver
submissions with a form service such as Formspree, make onValid post the form
data first. In src/components/contact-form.tsx:
const { errors, form, describe, reset } = useValidatedForm({
groups: { services: "Please choose at least one service." },
onValid: async () => {
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form.ref.current!),
headers: { Accept: "application/json" },
});
if (res.ok) setSent(true);
},
});Do the same in ApplicationForm. All fields have name attributes. If you'd rather handle submissions
yourself, remove output: "export" from next.config.ts (see below) and post to a Route Handler such as
src/app/api/brief/route.ts:
export async function POST(request: Request) {
const data = await request.formData();
// validate, then email it or store it
return Response.json({ ok: true });
}Saved data (localStorage)#
This template doesn't store anything in the browser.
SEO and sharing#
-
Site-wide defaults are the
metadataexport insrc/app/layout.tsx(with a title template%s — Northbound Studio); pages usepageMetadata(title, description)fromsrc/lib/metadata.ts. -
metadataBasecomes fromNEXT_PUBLIC_SITE_URL(defaulthttps://northbound.example). Set it to your domain when building so the social image URL is absolute:shNEXT_PUBLIC_SITE_URL=https://example.com pnpm build -
The social image is
public/img/og-image.png(1200×630). -
Add
src/app/sitemap.tsandsrc/app/robots.tsfor a sitemap and robots file; both work with static export.
Accessibility#
Built in: a skip link to #main, landmarks, visible focus rings (lime on dark .on-dark sections),
keyboard support for the menu, carousel, filters, slider and role details, the ARIA carousel pattern with
a live status, linked form errors, and reduced-motion support for every animation. Contrast meets WCAG
AA.
When editing, keep one <h1> per page, keep heading levels in order, label new fields and add a
FieldError for them, and give images meaningful alt text.
Building and deploying#
pnpm build # static site in out/
pnpm start # preview out/next.config.ts uses output: "export" and images.unoptimized. DEMO_BASE_PATH sets basePath for
sub-folder hosting (e.g. DEMO_BASE_PATH=/agency pnpm build) and is exposed as
NEXT_PUBLIC_BASE_PATH for asset().
- Vercel: import the repository; no settings needed.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directoryout. - Any static host: upload the contents of
out/.
For server features (Route Handlers, Server Actions), remove output: "export" and images: { unoptimized: true } and deploy to a host that runs Next.js. demo/, if present, is the freeframe.dev
demo build and can be deleted.
TypeScript 7 setup#
@typescript/native provides TypeScript 7's tsc for pnpm typecheck; typescript is aliased to
@typescript/typescript6 because typescript-eslint and parts of the Next.js toolchain need the
TypeScript 6 API. Replace both with typescript@7 once typescript-eslint supports it.
eslint.config.mjs sets settings.react.version, which ESLint 10 needs.
Troubleshooting#
- Images 404 when hosted in a sub-folder. Use
asset("/img/...")for every/publicpath and build withDEMO_BASE_PATH. - A new case study doesn't appear under its filter. Its
industrymust be a value fromindustries. - "Apply for this role" doesn't preselect. The role title must match an option in the application
form, which is built from
roles. - A local time is blank. The
tzvalue isn't a valid IANA time zone. - Social previews have no image. Build with
NEXT_PUBLIC_SITE_URLset to your domain. - ESLint crashes with
getFilename is not a function. Keepsettings.react.versionineslint.config.mjs. next buildfails after adding an API route. Removeoutput: "export".
License#
MIT. The code and all included artwork are free for personal and commercial use; the download includes
the license text in LICENSE.txt. The fonts are licensed under the SIL Open Font License.