Web App
Grit scaffolds a full Next.js 14+ frontend with App Router, Tailwind CSS, shadcn/ui, React Query, and a pre-built authentication flow -- all wired to your Go API out of the box.
Architecture Overview
The web app lives at apps/web/ inside the monorepo. It uses Next.js App Router for file-based routing, Tailwind CSS with shadcn/ui for the design system, and React Query for all data fetching. The dark theme is enabled by default, matching the premium aesthetic across the entire Grit stack.
apps/web/ ├── app/ │ ├── layout.tsx # Root layout with providers │ ├── page.tsx # Landing / home page │ ├── globals.css # Tailwind + dark theme variables │ ├── (auth)/ # Auth route group (public) │ │ ├── login/page.tsx │ │ ├── register/page.tsx │ │ └── forgot-password/page.tsx │ ├── (dashboard)/ # Protected route group │ │ ├── layout.tsx # Sidebar + auth guard │ │ └── dashboard/page.tsx # Main dashboard │ └── blog/ # Public blog pages │ ├── page.tsx # Blog listing page │ └── [slug]/page.tsx # Blog detail page ├── components/ │ ├── ui/ # shadcn/ui primitives │ ├── navbar.tsx # Site navigation bar │ ├── footer.tsx # Site footer │ ├── providers.tsx # QueryClient + theme providers │ └── shared/ │ └── providers.tsx # QueryClient + theme providers ├── hooks/ │ ├── use-auth.ts # Auth hooks (login, register, etc.) │ ├── use-users.ts # Generated CRUD hooks │ └── use-blogs.ts # Blog data fetching hooks ├── lib/ │ ├── api.ts # API helper functions │ ├── api-client.ts # Axios instance with JWT interceptor │ ├── query-client.ts # React Query client config │ └── utils.ts # Utility functions (cn, etc.) ├── next.config.ts ├── tailwind.config.ts ├── tsconfig.json └── package.json
App Router Structure
Grit uses Next.js route groups to separate public and protected routes. The (auth) group holds login, register, and forgot-password pages. The (dashboard) group wraps all authenticated pages with a sidebar layout and auth guard.
Root Layout
The root layout wraps the entire app with the Providers component, which sets up React Query and the theme. The DM Sans font is loaded via Google Fonts and JetBrains Mono is used for code elements.
import type { Metadata } from "next";
import { Providers } from "@/components/shared/providers";
import "./globals.css";
export const metadata: Metadata = {
title: "My App",
description: "Built with Grit",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}Providers Component
The Providers component initializes React Query with a shared QueryClient instance and wraps children with the QueryClientProvider. Toast notifications use Sonner.
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { queryClient } from "@/lib/query-client";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster theme="dark" position="top-right" />
</QueryClientProvider>
);
}Authentication Pages
Grit ships three pre-built auth pages with the dark theme, form validation via Zod schemas from the shared package, and API integration via the auth hooks. All pages are fully responsive.
| Route | Page | Description |
|---|---|---|
| /login | Login | Email + password, JWT token storage |
| /register | Register | Name + email + password + confirm |
| /forgot-password | Forgot Password | Email input, triggers reset email |
Here is the login page pattern. It uses the useLogin() hook from hooks/use-auth.ts and validates with the LoginSchema from the shared package.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { LoginSchema, type LoginInput } from "@myapp/shared/schemas";
import { useLogin } from "@/hooks/use-auth";
export default function LoginPage() {
const router = useRouter();
const login = useLogin();
const [form, setForm] = useState<LoginInput>({
email: "", password: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
const result = LoginSchema.safeParse(form);
if (!result.success) {
const fieldErrors: Record<string, string> = {};
result.error.issues.forEach((issue) => {
fieldErrors[issue.path[0] as string] = issue.message;
});
setErrors(fieldErrors);
return;
}
login.mutate(form, {
onSuccess: () => router.push("/dashboard"),
onError: (err) => setErrors({ form: "Invalid credentials" }),
});
};
return (
<div className="min-h-screen flex items-center justify-center">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
<h1 className="text-2xl font-bold">Sign In</h1>
{/* Email and password inputs with error display */}
{/* Submit button with loading state */}
<p className="text-sm text-text-muted">
Don't have an account?{" "}
<Link href="/register" className="text-accent">Register</Link>
</p>
</form>
</div>
);
}Protected Dashboard Layout
The (dashboard) route group wraps all authenticated pages. Its layout component checks for a valid JWT token and redirects unauthenticated users to the login page. It renders a sidebar for navigation and a top navbar with the user avatar and logout button.
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useMe } from "@/hooks/use-auth";
import Cookies from "js-cookie";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { data: user, isLoading, isError } = useMe();
useEffect(() => {
const token = Cookies.get("access_token");
if (!token) {
router.replace("/login");
}
}, [router]);
useEffect(() => {
if (isError) {
Cookies.remove("access_token");
Cookies.remove("refresh_token");
router.replace("/login");
}
}, [isError, router]);
if (isLoading) {
return <div className="min-h-screen bg-background" />;
}
return (
<div className="min-h-screen bg-background flex">
{/* Sidebar navigation */}
<aside className="hidden lg:flex w-64 flex-col border-r border-border">
{/* Logo, nav links, user section */}
</aside>
{/* Main content */}
<main className="flex-1">
{/* Top navbar with user avatar */}
<div className="p-6">{children}</div>
</main>
</div>
);
}The dashboard page itself shows a welcome message, stats cards (total users, active sessions, etc.), and a recent activity feed. Stats are fetched from the Go API using React Query.
"use client";
import { useMe } from "@/hooks/use-auth";
export default function DashboardPage() {
const { data: user } = useMe();
return (
<div>
<h1 className="text-2xl font-bold mb-6">
Welcome back, {user?.name}
</h1>
{/* Stats cards grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<StatsCard label="Total Users" value="1,234" />
<StatsCard label="Active Sessions" value="56" />
<StatsCard label="Revenue" value="$12,345" />
<StatsCard label="Growth" value="+12.3%" />
</div>
{/* Recent activity */}
<div className="rounded-xl border border-border bg-bg-secondary p-6">
<h2 className="text-lg font-semibold mb-4">Recent Activity</h2>
{/* Activity list */}
</div>
</div>
);
}API Client Setup
The API client is an Axios instance configured to point at the Go backend. It automatically injects the JWT access token from cookies into every request, and handles token refresh when a 401 response is received.
import axios from "axios";
import Cookies from "js-cookie";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";
export const apiClient = axios.create({
baseURL: API_URL,
headers: { "Content-Type": "application/json" },
});
// Inject JWT token into every request
apiClient.interceptors.request.use((config) => {
const token = Cookies.get("access_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle 401 by refreshing the token
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = Cookies.get("refresh_token");
const { data } = await axios.post(
`${API_URL}/api/auth/refresh`,
{ refresh_token: refreshToken }
);
Cookies.set("access_token", data.data.access_token);
Cookies.set("refresh_token", data.data.refresh_token);
originalRequest.headers.Authorization =
`Bearer ${data.data.access_token}`;
return apiClient(originalRequest);
} catch {
Cookies.remove("access_token");
Cookies.remove("refresh_token");
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);React Query Setup
The React Query client is configured with sensible defaults: no automatic refetching on window focus in development, stale time of 30 seconds, and retry of 1 attempt on failure.
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // 30 seconds
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 0,
},
},
});Tailwind CSS + shadcn/ui
The web app uses Tailwind CSS with a custom dark theme and shadcn/ui components. All CSS variables are defined in globals.css and mapped to Tailwind utility classes in tailwind.config.ts.
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #111118;
--bg-tertiary: #1a1a24;
--bg-elevated: #22222e;
--bg-hover: #2a2a38;
--border: #2a2a3a;
--text-primary: #e8e8f0;
--text-secondary:#9090a8;
--text-muted: #606078;
--accent: #6c5ce7;
--accent-hover: #7c6cf7;
--success: #00b894;
--danger: #ff6b6b;
--warning: #fdcb6e;
--info: #74b9ff;
}import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
],
theme: {
extend: {
colors: {
background: "var(--bg-primary)",
"bg-secondary": "var(--bg-secondary)",
"bg-tertiary": "var(--bg-tertiary)",
border: "var(--border)",
foreground: "var(--text-primary)",
accent: {
DEFAULT: "var(--accent)",
hover: "var(--accent-hover)",
},
success: "var(--success)",
danger: "var(--danger)",
},
fontFamily: {
sans: ["DM Sans", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "monospace"],
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;Dark Theme by Default
Grit uses a dark-first design philosophy. The dark class is applied to the root <html> element in the root layout. The color palette is inspired by premium tools like Linear, Vercel Dashboard, and Raycast.
| Token | Value | Usage |
|---|---|---|
| --bg-primary | #0a0a0f | Page background |
| --bg-secondary | #111118 | Cards, sidebar |
| --accent | #6c5ce7 | Buttons, links, highlights |
| --text-primary | #e8e8f0 | Headings, body text |
| --danger | #ff6b6b | Error states, delete actions |
Running the Web App
Start the web app in development mode. Make sure the Go API is running first.
Or use Turborepo to start all apps at once from the project root: