Playwright for E2E Testing: A Practical Guide for Frontend Developers
Learn end-to-end testing with Playwright — writing reliable tests, handling authentication, testing across browsers, and integrating with CI/CD pipelines.
A practical guide to monorepo architecture — when to use monorepos, setting up Turborepo or Nx, shared packages, caching, and CI/CD optimization.
By codefunc
Monorepos consolidate multiple projects into a single repository. Instead of separate repos for your web app, mobile app, shared components, and utilities, everything lives together. This simplifies code sharing, atomic changes across projects, and consistent tooling. The tradeoff is complexity in build systems and CI.
Bottom line: Use a monorepo when you have multiple related packages that share code. Use Turborepo for simplicity and speed. Use Nx for larger teams needing advanced features. Configure caching and affected-only builds from day one. Monorepos shine for design systems, shared utilities, and apps with common business logic.
| Benefit | Description |
|---|---|
| Code sharing | Import shared packages directly |
| Atomic changes | Update library and consumers in one commit |
| Consistent tooling | Same lint, test, build config everywhere |
| Simplified dependencies | Single lock file, no version mismatches |
| Better refactoring | IDE understands entire codebase |
| Challenge | Solution |
|---|---|
| Slow builds | Caching, affected-only builds |
| Large repo size | Sparse checkout, shallow clone |
| CI complexity | Task orchestration (Turborepo/Nx) |
| Ownership boundaries | CODEOWNERS, project constraints |
Turborepo is a fast, simple monorepo build system.
npx create-turbo@latest my-monorepo
cd my-monorepo
my-monorepo/
├── apps/
│ ├── web/ # Next.js app
│ │ └── package.json
│ └── docs/ # Documentation site
│ └── package.json
├── packages/
│ ├── ui/ # Shared components
│ │ └── package.json
│ ├── utils/ # Shared utilities
│ │ └── package.json
│ └── config/ # Shared configs (ESLint, TS)
│ └── package.json
├── package.json # Root package.json
├── turbo.json # Turborepo config
└── pnpm-workspace.yaml # Workspace definition
{
"name": "my-monorepo",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"lint": "turbo lint",
"test": "turbo test"
},
"devDependencies": {
"turbo": "^2.0.0"
},
"packageManager": "pnpm@9.0.0"
}
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"test": {
"dependsOn": ["build"]
}
}
}
packages:
- "apps/*"
- "packages/*"
// apps/web/package.json
{
"name": "web",
"dependencies": {
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*"
}
}
// packages/ui/package.json
{
"name": "@repo/ui",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
// apps/web/app/page.tsx
import { Button } from '@repo/ui';
import { formatDate } from '@repo/utils';
export default function Home() {
return (
<div>
<Button>Click me</Button>
<p>{formatDate(new Date())}</p>
</div>
);
}
# Login to Vercel for remote cache
npx turbo login
# Link repo
npx turbo link
# Now builds are cached remotely
npx turbo build
Nx is a more feature-rich monorepo tool with advanced capabilities.
npx create-nx-workspace@latest my-nx-monorepo
my-nx-monorepo/
├── apps/
│ ├── web/
│ └── web-e2e/
├── libs/
│ ├── ui/
│ └── utils/
├── nx.json
├── package.json
└── tsconfig.base.json
{
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"cache": true
},
"test": {
"cache": true
},
"lint": {
"cache": true
}
},
"defaultBase": "main"
}
Nx has generators for scaffolding:
# Generate a new React library
nx g @nx/react:library ui --directory=libs/ui
# Generate a new Next.js app
nx g @nx/next:application web --directory=apps/web
# Generate a component
nx g @nx/react:component Button --project=ui
# Run single target
nx build web
# Run for all projects
nx run-many -t build
# Run only affected projects
nx affected -t build
# Visualize dependency graph
nx graph
Enforce import rules with tags:
// libs/ui/project.json
{
"tags": ["scope:shared", "type:ui"]
}
// .eslintrc.json
{
"rules": {
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": ["type:ui", "type:util"]
},
{
"sourceTag": "type:ui",
"onlyDependOnLibsWithTags": ["type:util"]
}
]
}
]
}
}
// packages/config-eslint/package.json
{
"name": "@repo/eslint-config",
"main": "index.js"
}
// packages/config-eslint/index.js
module.exports = {
extends: ['next', 'turbo', 'prettier'],
rules: {
'@next/next/no-html-link-for-pages': 'off',
},
};
// apps/web/.eslintrc.js
module.exports = {
root: true,
extends: ['@repo/eslint-config'],
};
// packages/config-typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
}
}
// apps/web/tsconfig.json
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for affected
- uses: pnpm/action-setup@v3
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install
# Turborepo
- run: pnpm turbo build --filter=...[origin/main]
# Or Nx
- run: npx nx affected -t build --base=origin/main
# Turborepo remote caching
- run: pnpm turbo build
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
detect:
runs-on: ubuntu-latest
outputs:
projects: ${{ steps.affected.outputs.projects }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: affected
run: |
PROJECTS=$(npx nx show projects --affected --base=origin/main --json)
echo "projects=$PROJECTS" >> $GITHUB_OUTPUT
build:
needs: detect
runs-on: ubuntu-latest
strategy:
matrix:
project: ${{ fromJson(needs.detect.outputs.projects) }}
steps:
- run: npx nx build ${{ matrix.project }}
// packages/ui/package.json
{
"name": "@repo/ui",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./button": "./src/button.tsx"
}
}
Consuming apps bundle directly from source.
// packages/ui/package.json
{
"name": "@repo/ui",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts"
}
}
For internal packages:
"@repo/ui": "workspace:*"
For publishable packages, use changesets:
npx changeset init
npx changeset # Create changeset
npx changeset version # Update versions
npx changeset publish # Publish to npm
pnpm install after adding packagesname matches importoutputs in task configtsconfig.json referencesexports field is correctUse Text Diff to compare configurations between packages. Format package.json files with JSON Formatter. Generate gitignore for monorepos with Gitignore Generator.
Monorepos work well for related projects sharing code. Start with Turborepo for simplicity — it handles caching and task orchestration with minimal config. Graduate to Nx if you need generators, project constraints, or advanced affected analysis. Configure remote caching from day one. Use affected-only builds in CI to keep pipelines fast. Internal packages without a build step are simplest for sharing components.
Learn end-to-end testing with Playwright — writing reliable tests, handling authentication, testing across browsers, and integrating with CI/CD pipelines.
Why compact GraphQL queries hurt reviews, how to format selection sets consistently, and a browser workflow before you paste into Apollo or GraphiQL.
Shrink SVG icons for the web — strip comments and whitespace, choose inline vs data URLs, and know when you still need a full SVGO pipeline.
Find a developer tool