Overview#
Counsel is a complete website for a law firm or professional services practice, shown as Hartwell & Sloane LLP, a fictional partner-led firm with offices in Boston, Hartford and Providence. It has practice areas with an audience filter, an attorney directory with search, case results, insights articles with a reading progress bar, and a consultation request form with a conflict-check notice. This is the Next.js version (1.0.1): Next.js 16 (App Router), React 19, TypeScript and Tailwind CSS v4, exported as a static site.
Live demo: freeframe.dev/templates/counsel
Quick start#
Requirements: Node.js 24 and pnpm (corepack enable pnpm) or npm.
pnpm install
pnpm dev # http://localhost:3000| Command | What it does |
|---|---|
pnpm dev |
Development server at http://localhost:3000 |
pnpm build |
Static site in out/ |
pnpm start |
Serve out/ locally (npx serve out) |
pnpm lint |
ESLint (Next.js core-web-vitals and TypeScript rules) |
pnpm typecheck |
Generate route types (next typegen), then type-check with tsc (TypeScript 7) |
pnpm build:demo |
Build for /demos/nextjs/counsel into demo/ (the freeframe.dev demo; you can delete it) |
Pages#
| Route | File | Notable sections and features |
|---|---|---|
/ |
src/app/page.tsx |
Hero with courthouse illustration, count-up statistics, practice areas, how we work, attorneys, client quotes, insights, call to action |
/practice-areas |
src/app/practice-areas/page.tsx |
Practice areas filtered by audience (PracticeGrid) |
/practice-area |
src/app/practice-area/page.tsx |
One practice in depth (Employment Law): services, process, results, FAQ, team |
/attorneys |
src/app/attorneys/page.tsx |
Directory with name search and practice filter (AttorneyDirectory) |
/attorney |
src/app/attorney/page.tsx |
Profile of Priya Raman: bio, contact, matters, publications, admissions, education |
/results |
src/app/results/page.tsx |
Results filtered by outcome and practice (ResultsBoard), with disclaimers |
/insights |
src/app/insights/page.tsx |
Articles filtered by topic (InsightsList), newsletter sign-up |
/article |
src/app/article/page.tsx |
Long-form article with reading progress and a table of contents |
/contact |
src/app/contact/page.tsx |
Consultation request form, office details, illustrated map |
| 404 | src/app/not-found.tsx |
Page not found |
The detail pages (/practice-area, /attorney, /article) are single examples that every card links to. See
Content for turning them into one page per item.
Project structure#
src/
├── app/
│ ├── layout.tsx fonts, metadata, header, footer, reveal controller, back-to-top
│ ├── page.tsx home
│ ├── <route>/page.tsx one folder per page
│ ├── not-found.tsx 404 page
│ ├── globals.css Tailwind import, design tokens (@theme) and component classes
│ └── icon.svg favicon
├── components/
│ ├── site-header.tsx header, navigation (NAV) and mobile menu
│ ├── site-footer.tsx footer with the attorney-advertising notice
│ ├── cards.tsx PracticeCard, AttorneyCard, ArticleCard, ResultCard, Chips
│ ├── practice-grid.tsx, attorney-directory.tsx, results-board.tsx, insights-list.tsx filterable lists
│ ├── article.tsx ReadingProgress and ArticleToc
│ ├── count-up.tsx animated statistics
│ ├── forms.tsx ConsultationForm and NewsletterForm with validation
│ ├── icons.tsx Icon (by name) and Arrow
│ ├── reveal-controller.tsx, back-to-top.tsx
├── data/site.ts firm details, offices, practices, attorneys, results, topics, articles
├── fonts/ self-hosted Libre Caslon Display and IBM Plex Sans (with OFL licences)
└── lib/
├── asset.ts asset(): prefixes public files with the base path
└── metadata.ts pageMetadata(): title, description and Open Graph per page
public/img/ SVG portraits, facade, article covers, og-image.svg and og-image.png
next.config.ts static export and optional base pathCustomising#
Brand and colours#
Design tokens are in the @theme block of src/app/globals.css:
| Token | Used for |
|---|---|
--color-ink, --color-ink-2 |
Text and deep navy sections; raised panels on navy |
--color-paper, --color-paper-2 |
Page background (warm ivory) and sunken panels |
--color-surface |
Cards |
--color-line, --color-line-dark |
Hairlines on ivory and on navy |
--color-muted, --color-mist |
Secondary text on ivory and on navy |
--color-gold |
Decorative brass: rules, icons, borders |
--color-gold-ink, --color-gold-soft |
Brass for text on ivory and on navy (both meet AA contrast) |
--color-danger |
Error messages |
@theme {
--color-gold: #4f7a62;
--color-gold-ink: #2f5a43;
--color-gold-soft: #9cc7ae;
}Tailwind compiles during pnpm dev/pnpm build; there's no separate CSS step. The browser theme colour is
viewport.themeColor in src/app/layout.tsx.
Typography#
The fonts are self-hosted with next/font/local in src/app/layout.tsx, using the same Latin .woff2 files Google
Fonts serves (their SIL Open Font License files are in src/fonts/):
const caslon = localFont({ src: "../fonts/LibreCaslonDisplay-latin.woff2", variable: "--font-caslon", weight: "400", display: "swap" });
const plex = localFont({ src: "../fonts/IBMPlexSans-latin.woff2", variable: "--font-plex", weight: "400 700", display: "swap" });--font-display and --font-sans in globals.css use these variables. To change a font, add its .woff2 to
src/fonts/ and point localFont at it, or switch to next/font/google with the same variable name.
Content#
Lists that repeat or are filtered come from src/data/site.ts:
| Export | What it holds |
|---|---|
FIRM |
Name, legal name, year founded, phone (phone and phone_href), email |
OFFICES |
Offices: slug, city, label, address lines, phone, hours, and map pin coordinates x/y |
PRACTICES |
Practice: slug, name, audience ("business" | "individual"), icon (an IconName), summary, lead (an attorney slug) |
ATTORNEYS |
Attorney: slug, name, role, practices (practice slugs), office (office slug), email, bio, bars, education, optional publications |
ROLE_ORDER |
Sort order of roles in the directory (Founding Partner first) |
RESULTS, RESULT_TYPES |
Result: practice, type (one of RESULT_TYPES), figure, year, title |
TOPICS, ARTICLES |
Insight topics and Article: slug, title, topic, date, date_label, author (attorney slug), minutes, excerpt, art (cover number) |
Helpers attorneyBySlug(), practiceBySlug(), officeBySlug() and topicKey() look items up for the cards.
To add an attorney:
{
slug: "jane-doe",
name: "Jane Doe",
role: "Associate",
practices: ["employment"],
office: "hartford",
email: "jdoe@hartwellsloane.example",
bio: "…",
bars: ["Connecticut"],
education: ["J.D., …"],
},and add public/img/attorney-jane-doe.svg (or .jpg, 400 × 480). The directory, filters and sort pick it up. Icons for
practices are the keys of PATHS in icons.tsx (building, scales, people, spark, key, document, home,
shield, …).
Written directly in page files (edit them there): the home page sections and statistics, the three detail pages, the
office details on /contact and the footer, and the legal notices.
One page per item. Every card links to the single example page (/attorney, /practice-area, /article). To
publish a page for each attorney, move src/app/attorney/page.tsx to src/app/attorneys/[slug]/page.tsx, read the
attorney with attorneyBySlug((await params).slug), export generateStaticParams() returning every slug (required for
static export), and change the card link in AttorneyCard to `/attorneys/${attorney.slug}`. Practices and articles
work the same way (give each article a unique slug; they're all "article" in the sample data).
Images and artwork#
Files in public/img/ |
Size | Where |
|---|---|---|
attorney-<slug>.svg |
400 × 480 (5:6) | Attorney cards and profile |
insight-0.svg … insight-5.svg |
640 × 400 (16:10) | Article covers (art number) |
facade.svg |
640 × 560 | Home hero |
og-image.png (source og-image.svg) |
1200 × 630 | Social image used by pageMetadata() and the layout |
src/app/icon.svg is the favicon. Images use next/image with images.unoptimized (required for static export), and
paths go through asset() so they work under a base path:
<Image src={asset(`/img/attorney-${attorney.slug}.svg`)} alt={`Portrait of ${attorney.name}`} width={400} height={480} />Replace SVGs with .jpg/.webp photos of the same proportions and update the extensions.
Navigation, header and footer#
site-header.tsx holds the NAV array and a SECTION map that highlights the parent section on detail pages (for
example /attorney highlights "Attorneys"); the current link gets aria-current="page". To add a page, create
src/app/<route>/page.tsx, export metadata = pageMetadata(title, description) and add it to NAV. The footer is in
site-footer.tsx, including the attorney-advertising and not-legal-advice notice; review it and the page-level
disclaimers on /results, /article and /contact against your bar's advertising rules.
Features#
Filterable lists#
Each list is a client component that filters data from site.ts in React state:
| Component | Page | Controls |
|---|---|---|
PracticeGrid |
/practice-areas |
Audience chips (all, businesses, individuals) |
AttorneyDirectory |
/attorneys |
Name search, practice select; sorted by ROLE_ORDER; empty state and reset |
ResultsBoard |
/results |
Outcome chips (RESULT_TYPES), practice select; empty state and reset |
InsightsList |
/insights |
Topic chips (TOPICS) |
Chips are toggle buttons (aria-pressed, the shared Chips component in cards.tsx) and each list announces its count
in a polite live region. Add items to the data and they appear in the right filters automatically.
Count-up statistics#
<CountUp to={1200} suffix="+" /> animates from 0 when it scrolls into view and shows the final value at once with
reduced motion.
Reading progress and table of contents#
article.tsx exports ReadingProgress (a bar that fills as you read the article body) and ArticleToc (links that get
aria-current for the section in view). Give each section heading an id and list it in the table of contents.
Header, mobile menu and scroll reveals#
The header shows a shadow after scrolling. The menu button toggles the mobile menu (aria-expanded, Esc closes it and
returns focus, and it closes on navigation). RevealController fades in .reveal elements as they scroll into view and
reveals everything above them too; with reduced motion, or before JavaScript runs (the js class is added by a small
script in the layout), everything is visible.
Back to top#
back-to-top.tsx, mounted once in the layout, shows a button after about one screen of scrolling
(useSyncExternalStore on scroll and resize). It scrolls smoothly (instantly with reduced motion) and moves focus to
<main> when the scroll ends. Restyle it with the .back-to-top rules in globals.css, or remove <BackToTop /> from
the layout to drop it. If you add fixed UI to the bottom-right corner (a cookie bar or chat launcher), give it room or
move the button with CSS, since this version doesn't reposition itself around other elements.
Forms#
| Form | Component | Validates |
|---|---|---|
| Consultation request | ConsultationForm in forms.tsx (on /contact) |
Name, email, who it's for (required radio group), practice, a message of at least 20 characters, the acknowledgement checkbox |
| Newsletter | NewsletterForm in forms.tsx (on /insights) |
Both use the useValidation() hook in forms.tsx: native constraints are checked on blur and submit, errors render
under each field (err-<name>, linked with aria-describedby), required radio groups are passed as
useValidation({ for: "Please choose who the consultation is for." }), and focus moves to the first problem. On success
the form hides and a focused success panel appears, with a button to send another.
Nothing is sent. To receive submissions with a form service such as Formspree, make the submit handler async and post the form where the demo comment is:
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
if (!v.validateAll(form)) return;
const res = await fetch("https://formspree.io/f/your-id", {
method: "POST",
body: new FormData(form),
headers: { Accept: "application/json" },
});
if (!res.ok) return; // show an error message here
setSent(true);
requestAnimationFrame(() => successRef.current?.focus());
};Do the same in NewsletterForm, and update the success text, which says the form is a demo. If you remove the static
export, you can post to a Route Handler (e.g. src/app/api/consultation/route.ts) or use a Server Action
instead. Keep the conflict-check notice, and don't ask for confidential details before a conflict check.
SEO and sharing#
- Defaults are in
metadatainsrc/app/layout.tsx: a title template (%s — Hartwell & Sloane), description and Open Graph image. - Each page exports
metadata = pageMetadata(title, description)fromsrc/lib/metadata.ts. metadataBaseisprocess.env.NEXT_PUBLIC_SITE_URL(falling back tohttps://hartwellsloane.example). Set it when you build so the social image URL is absolute:
NEXT_PUBLIC_SITE_URL=https://www.your-firm.com pnpm buildConsider adding src/app/sitemap.ts and src/app/robots.ts (both work with static export) and
LegalService structured data (JSON-LD) on the home page.
Accessibility#
Built in: a skip link and landmarks, one h1 per page, visible brass focus rings, toggle chips with aria-pressed,
counts in live regions, aria-current for navigation and the table of contents, form errors linked with
aria-describedby and focus moved to the first error, an accessible mobile menu, a titled office map, reduced motion
respected everywhere, and colours that meet WCAG AA (with dedicated brass shades for text on ivory and navy).
When editing, keep one h1 per page, label every input, use gold-ink/gold-soft rather than gold for brass text,
and keep alt text meaningful.
Building and deploying#
pnpm build writes a static site to out/ (output: "export" in next.config.ts):
- Vercel: import the repository; no settings needed.
- Netlify / Cloudflare Pages: build command
pnpm build, publish directoryout. - Any static host: upload the contents of
out/.
To serve from a sub-folder, build with DEMO_BASE_PATH=/firm pnpm build; next.config.ts passes it to basePath and
NEXT_PUBLIC_BASE_PATH, which asset() uses. pnpm build:demo does this for the freeframe.dev demo (demo/, which you
can delete).
To use server features (Route Handlers, Server Actions), remove output: "export" and images.unoptimized from
next.config.ts and deploy to a Node-capable host such as Vercel.
TypeScript setup#
package.json installs @typescript/native (TypeScript 7, which provides tsc) and typescript as an alias of
@typescript/typescript6, because typescript-eslint and parts of the Next.js toolchain still need the TypeScript 6
API. When typescript-eslint supports TypeScript 7, replace both with typescript@7. eslint.config.mjs pins
settings.react.version, because eslint-plugin-react can't detect it under ESLint 10.
Troubleshooting#
generateStaticParamserrors after adding[slug]routes. Static export needs every slug listed; returnATTORNEYS.map((a) => ({ slug: a.slug }))(and likewise for practices and articles).- A new practice icon fails to type-check.
iconmust be a key ofPATHSinicons.tsx; add a path there first. - An attorney appears in the wrong filter.
practicesholds practice slugs ("real-estate"), not names. - Images break under a sub-folder. Build with
DEMO_BASE_PATH, and always wrap public paths inasset(). - Social previews use
hartwellsloane.example. SetNEXT_PUBLIC_SITE_URLwhen building. pnpm lintcrashes withcontextOrFilename.getFilename is not a function. Keepsettings.react.versionineslint.config.mjs.- The form says it was sent but nothing arrived. It's a demo until connected (see Forms).
License#
MIT: the full text is in LICENSE.txt, included in the download. You can use, change and sell sites built with this
template, including the SVG artwork. The fonts are licensed under the SIL Open Font License (see src/fonts/).
Hartwell & Sloane and everyone in this template are fictional.