When we set out to build the Justice AI Unit's public-facing website, we wanted something that reflected how we actually work. Opinionated tooling, strong defaults, and a visual identity that sits outside the usual GOV.UK template. The result: a statically exported Next.js site inside a Turborepo monorepo, styled with Tailwind CSS, with a brand-defined shadcn/ui registry and custom pixel dither engine we built to give the Unit a visual signature.
This post covers the technical decisions behind the site — what we chose, what we ruled out, and why these decisions will benefit us in the short and long term.
Why we rebuilt from scratch
The team needed a home on the internet. More than that, we needed a platform that could grow alongside the Unit: hosting blog posts, explaining our strategic framework, publishing case studies from our "Scan, Pilot, Scale" delivery model, and recruiting engineers and fellows.
We considered off-the-shelf solutions, but we wanted full ownership of the content pipeline, component system, and deployment model. We wanted something engineers on the team could contribute to directly: blog posts in Markdown, design changes in a type-safe environment, and shipping without a staging bottleneck.
The technology stack
Here is what we landed on:
| Technology | Role |
|---|---|
| Next.js | App Router with static export and experimental view transitions |
| TypeScript | End-to-end type safety across the entire codebase |
| Tailwind CSS | Utility-first styling with tw-animate-css for animation primitives |
| Turborepo | Monorepo orchestration with pnpm workspaces |
| Fumadocs | MDX content pipeline with Zod-validated frontmatter |
| Framer Motion | Layout animations, scroll-reactive UI, and spring physics |
| Shiki + twoslash | Syntax highlighting with inline TypeScript type annotations |
| Radix UI | Accessible primitives via a shared shadcn/ui component package |
| Biome + Ultracite | Unified linting and formatting in a single pass |
The site is fully statically generated at build time and served from a CDN. No server runtime. This keeps things simple, fast, and cheap to host.
Why Next.js with static export
We chose Next.js for its mature MDX support, file-system routing, and the ecosystem around it. But since this is a content site with no dynamic data fetching or authentication, a full server runtime would be unnecessary overhead. Static export gives us the React component model and the Next.js build pipeline without any server infrastructure to manage.
Turbopack handles development for fast refresh, and Webpack handles the production build where Fumadocs MDX processing needs it.
Why Tailwind CSS
Tailwind was an easy choice. The engine is fast, the configuration model via CSS is clean, and the @tailwindcss/postcss plugin integrates
directly into the Next.js build. Paired with tw-animate-css, we get animation primitives that work naturally alongside Framer Motion.
We use Tailwind's container queries extensively. Grid tiles use @container queries to adapt their internal layout based on available space
rather than viewport width. Components respond to the space they are given, not where they happen to be placed.
Monorepo architecture
Rather than packing everything into a single application, we split the project into focused packages that each own a clear concern:
apps/
web/ # Next.js application
packages/
blog/ # Blog rendering, pagination, series/category logic
illustrations/ # SVG illustrations and interactive diagrams
shadverse/ # shadcn/ui component library (Radix + Tailwind)
ui/ # Shared presentational componentsThe blog package owns all the rendering logic for posts, series, and categories. The main app hands it data and gets pages back. Our
shadverse package is a versioned copy of shadcn/ui components that we extend as needed, keeping low-level UI primitives out of application
code. The illustrations package holds more complex visuals like our "Scan, Pilot, Scale" framework diagram.
Turborepo ties it all together. Builds cascade through dependency edges, dev servers spin up in parallel, and outputs are cached so that only the packages you actually changed need to rebuild. In practice, a change to a blog post never triggers a rebuild of the component library, and vice versa.
The content pipeline
All content lives in a top-level content/ directory as MDX files. The pipeline runs on Fumadocs:
- Zod-validated frontmatter with typed schemas for blog posts and case studies
- Rehype and remark plugins for KaTeX math rendering, Shiki code highlighting, and twoslash type annotations
- Custom MDX components including
<Tabs>,<Steps>,<CodeDisplay>,<GithubCodeBlock>, and<XEmbed>for embedded posts
Blog frontmatter is validated at build time against a Zod schema that enforces structure across every post:
const blog = defineCollections({
type: "doc",
dir: "../../content/blog",
schema: frontmatterSchema.extend({
author: z.string(),
date: z.string().or(z.date()).transform(/* ... */),
tags: z.array(z.string()).optional(),
image: z.string().optional(),
draft: z.boolean().optional().default(false),
series: z.string().optional(),
seriesPart: z.number().optional(),
}),
});A mistyped tag or a missing author field is caught during the build, not discovered in production. Drafts are excluded from production builds but visible during development, so authors can preview their work.
The twoslash integration is worth a mention. Code examples can include inline TypeScript type annotations resolved at build time by the
TypeScript compiler. Readers get hover-style type information rendered directly in code blocks, without needing an IDE. Combined with
Shiki's notation focus and meta highlight transformers, the result is static HTML with code presentation that would otherwise need a
client-side IDE — zero JavaScript required.
Defining our visual identity
Two components define the visual language of the site: a custom grid system for the editorial layout, and a pixel dither engine that gives our imagery its texture.
The grid system
The Justice AI design system is a distinctive editorial grid. Rather than a conventional full-width layout, a GridShell enforces a centred content column with decorative side rails:
export default function GridShell({ children, className, as: Component = "section" }: GridShellProps) {
return (
<Component className="@container grid grid-cols-[auto_1fr_auto] xl:grid-cols-[1fr_auto_1fr]">
<div aria-hidden="true" className="p-[0.5px]">
<div className="h-full w-2 rounded-r bg-card/90 lg:w-12 xl:w-full" />
</div>
<div className={cn("mx-auto w-full min-w-0 xl:min-w-276 xl:max-w-276", className)}>
{children}
</div>
<div aria-hidden="true" className="p-[0.5px]">
<div className="h-full w-2 rounded-l bg-card/90 lg:w-12 xl:w-full" />
</div>
</Component>
);
}The 0.5px padding creates sub-pixel gaps between grid tiles that produce a hairline border effect without explicit borders. The gaps are the
background colour bleeding through—a deliberate choice that creates a visual rhythm across the whole page.
Inside this shell, sections compose their own grid layouts. All page content is developed mobile-first, leaning into Tailwind's philosophy of responsive design, where the grid system facilitates the macro layout enabling section-specific behaviour to be encapsulated where it's needed.
The pixel dither engine
Every non-primary media image on the site passes through a custom DitherShader component before it reaches the screen. It's a client-side canvas renderer that applies Bayer ordered dithering—a technique rooted in digital halftoning where pixel brightness is quantised against a threshold matrix to produce a dot pattern that approximates continuous tone. The 4x4 Bayer matrix distributes threshold values in a way that minimises visual artefacts at any scale.
The result is the halftone texture you see across the site. Every photograph and illustration is reduced to two tones, giving the visual identity a cohesive, tactile quality that feels more like print than screen.
<DitherShader
colorMode="duotone"
ditherMode="bayer"
gridSize={3}
primaryColor="var(--secondary)"
secondaryColor="var(--card)"
src="/court-1.webp"
threshold={0.45}
/>The shader resolves CSS custom properties at paint time, so the same component adapts between light and dark themes automatically. We also
built a set of procedural SVG pattern generators that produce source imagery on the fly using SVG filter chains like feTurbulence and
feDisplacementMap. The dithering algorithm transforms these into the final output. No image files involved.
Try it yourself. The playground below runs the same Bayer dithering algorithm against a source photograph. Adjust the grid size to control dot density, shift the threshold to bias light and dark, pick your own duotone palette, and drag the fade position to blend the output into a gradient.
Accessibility
Building for a government context means accessibility is not optional. It's a core part of how we think about quality.
The site obeys semantic HTML throughout, and includes a skip-to-content link as the first focusable element on every page, ARIA landmarks (role="region", aria-label, aria-labelledby) on every major section, and aria-roledescription="carousel" on the hero with an aria-live="polite" region that announces slide changes to screen readers. prefers-reduced-motion is handled via Framer Motion's MotionConfig with reducedMotion="user", which respects the OS setting across all animated components. Decorative elements are marked aria-hidden="true" and canvas elements flagged as decorative so screen readers don't attempt to describe dithered imagery.
We treat accessibility the same way we treat security or performance: a constraint we design within from day one, not a phase at the end of a project. The justice system serves everyone, and the tools we build should reflect that. Every interactive component was tested for keyboard operability, every image has appropriate alt text or is correctly hidden from assistive technology, and every animation can be disabled by the user. These are not nice-to-haves. They are the baseline.
Developer experience
We chose our tools to make contributing fast and low-friction—as much today as when the codebase grows.
Turbopack powers the dev server. It starts in under a second with instant hot module replacement, so changes to a component or a blog post show up in the browser almost immediately.
Ultracite gives us a single command for linting and formatting across the entire monorepo. It wraps ESLint, Biome, and Prettier into one unified configuration. We chose this over managing three separate tool configs because consistency matters more than customisation at this scale. One command, one set of rules, zero arguments about semicolons.
Pre-commit hooks enforce formatting before code reaches the repository. Nothing gets merged without passing the linter—this is the same DevSecOps toolchain used across the Ministry of Justice.
Zod frontmatter validation catches content errors at build time. If a blog post has a malformed date or a missing author, the build fails with a clear message. Content contributors don't need to understand the rendering pipeline to write correct posts.
Type-safe MDX with @types/mdx and custom component exports means authors get full IDE support when writing. Autocomplete, error
highlighting, and go-to-definition all work inside .mdx files; the writing experience feels closer to working in application code than
editing a document.
Adding a new post means creating an MDX file in content/blog/, writing frontmatter that satisfies the schema, and publishing. The build
handles everything else: syntax highlighting, math rendering, type annotations, sitemap generation, static page creation. We've built this system with strong defaults: it does
the right thing without requiring the author to think about it.
What's next?
We are actively working on:
- Expanding the "AI in Action" case study section with richer interactive content
- Search, powered by Fumadocs' static search index
- Performance auditing and image optimisation
- More posts from across the team on our tools, methods, and findings
If you are interested in the intersection of AI, public services, and modern web engineering, we are hiring. And if you want to write about your work with us, this blog is the place to do it.
Last updated on